diff --git a/prisma/migrations/20260924000000_webhook_event_delivery_index/migration.sql b/prisma/migrations/20260924000000_webhook_event_delivery_index/migration.sql new file mode 100644 index 0000000..25a848d --- /dev/null +++ b/prisma/migrations/20260924000000_webhook_event_delivery_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "WebhookEvent_createdAt_idx" ON "WebhookEvent"("createdAt"); diff --git a/prisma/migrations/20260927130000_add_creator_teams/migration.sql b/prisma/migrations/20260927130000_add_creator_teams/migration.sql index a664e20..1a66484 100644 --- a/prisma/migrations/20260927130000_add_creator_teams/migration.sql +++ b/prisma/migrations/20260927130000_add_creator_teams/migration.sql @@ -241,4 +241,3 @@ ALTER TABLE "TeamPayout" ADD CONSTRAINT "TeamPayout_requestedById_fkey" FOREIGN -- AddForeignKey ALTER TABLE "TeamAuditLog" ADD CONSTRAINT "TeamAuditLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "CreatorTeam"("id") ON DELETE CASCADE ON UPDATE CASCADE; - diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 032d7fa..2bac6bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,42 +8,42 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - password String - name String? - bio String? - avatar String? - role String @default("fan") - verified Boolean @default(false) - authVersion Int @default(0) - failedLoginAttempts Int @default(0) - lockedUntil DateTime? - notificationPreferences Json @default("{}") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - creator Creator? - wallets Wallet[] - tips Tip[] - payments Payment[] - twoFactor TwoFactorAuth? - trustedDevices TrustedDevice[] - twoFactorEvents TwoFactorEvent[] - userRoles UserRole[] - sessions Session[] + id String @id @default(cuid()) + email String @unique + password String + name String? + bio String? + avatar String? + role String @default("fan") + verified Boolean @default(false) + authVersion Int @default(0) + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + notificationPreferences Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creator Creator? + wallets Wallet[] + tips Tip[] + payments Payment[] + twoFactor TwoFactorAuth? + trustedDevices TrustedDevice[] + twoFactorEvents TwoFactorEvent[] + userRoles UserRole[] + sessions Session[] passwordResetTokens PasswordResetToken[] } model TwoFactorAuth { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - secret String - backupCodes String[] - enabled Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + secret String + backupCodes String[] + enabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model TrustedDevice { @@ -54,6 +54,7 @@ model TrustedDevice { expiresAt DateTime createdAt DateTime @default(now()) lastUsedAt DateTime @default(now()) + @@index([userId, expiresAt]) } @@ -65,7 +66,10 @@ model TwoFactorEvent { success Boolean ip String? createdAt DateTime @default(now()) + @@index([userId, createdAt]) +} + model Role { id String @id @default(cuid()) name String @unique @@ -94,6 +98,7 @@ model RolePermission { role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + @@id([roleId, permissionId]) } @@ -104,6 +109,7 @@ model UserRole { role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) assignedAt DateTime @default(now()) assignedBy String? + @@id([userId, roleId]) @@index([roleId]) } @@ -117,8 +123,11 @@ model RoleChange { action String details Json? createdAt DateTime @default(now()) + @@index([actorId, createdAt]) @@index([targetId, createdAt]) +} + model Creator { id String @id @default(cuid()) userId String @unique @@ -129,30 +138,28 @@ model Creator { bio String? avatar String? - verified Boolean @default(false) - verifiedAt DateTime? + verified Boolean @default(false) + verifiedAt DateTime? verifiedUntil DateTime? - isPublic Boolean @default(true) - - totalEarnings Float @default(0) - pendingBalance Float @default(0) - - tips Tip[] @relation("CreatorTips") - webhooks Webhook[] - freezes AccountFreeze[] - payments Payment[] - payouts Payout[] + isPublic Boolean @default(true) + + totalEarnings Float @default(0) + pendingBalance Float @default(0) + tips Tip[] @relation("CreatorTips") + webhooks Webhook[] + freezes AccountFreeze[] + payments Payment[] + payouts Payout[] verificationRequests CreatorVerificationRequest[] - verificationEvents CreatorVerificationEvent[] + verificationEvents CreatorVerificationEvent[] + // Teams this creator belongs to (#66). teamMemberships CreatorTeamMember[] @relation("CreatorTeamMembers") + defaultAssetId String? - defaultAsset StellarAsset? @relation("CreatorDefaultAsset", fields: [defaultAssetId], references: [id], onDelete: SetNull) - verificationRequests CreatorVerificationRequest[] - verificationEvents CreatorVerificationEvent[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + defaultAsset StellarAsset? @relation("CreatorDefaultAsset", fields: [defaultAssetId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([verified, isPublic]) // Creator discovery feeds: visibility filter + deterministic keyset order. @@ -164,17 +171,17 @@ model Creator { } model CreatorVerificationRequest { - id String @id @default(cuid()) + id String @id @default(cuid()) creatorId String - creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) - status String @default("submitted") // submitted | approved | rejected | expired + creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) + status String @default("submitted") // submitted | approved | rejected | expired statement String? reviewerId String? reviewedAt DateTime? reviewReason String? expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt documents CreatorVerificationDocument[] events CreatorVerificationEvent[] @@ -184,29 +191,29 @@ model CreatorVerificationRequest { } model CreatorVerificationDocument { - id String @id @default(cuid()) + id String @id @default(cuid()) requestId String request CreatorVerificationRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) - storageKey String @unique + storageKey String @unique filename String contentType String size Int - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([requestId]) } model CreatorVerificationEvent { - id String @id @default(cuid()) + id String @id @default(cuid()) creatorId String - creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) + creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) requestId String? request CreatorVerificationRequest? @relation(fields: [requestId], references: [id], onDelete: SetNull) actorId String? action String // submitted | approved | rejected | expired | revoked reason String? metadata Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([creatorId, createdAt]) @@index([requestId, createdAt]) @@ -235,13 +242,13 @@ model Tip { creatorId String creator Creator @relation("CreatorTips", fields: [creatorId], references: [id], onDelete: Cascade) - amount Float - assetId String? - asset StellarAsset? @relation(fields: [assetId], references: [id], onDelete: SetNull) - assetCode String @default("USDC") - assetIssuer String? - assetDecimals Int @default(7) - message String? + amount Float + assetId String? + asset StellarAsset? @relation(fields: [assetId], references: [id], onDelete: SetNull) + assetCode String @default("USDC") + assetIssuer String? + assetDecimals Int @default(7) + message String? status String @default("pending") transactionHash String? @unique @@ -252,13 +259,13 @@ model Tip { // returns the original tip instead of creating a duplicate charge. idempotencyKey String? @unique -// Tip message media (#64): images and short videos uploaded before the tip. - media TipMedia[] + // Tip message media (#64): images and short videos uploaded before the tip. + media TipMedia[] // Moderation state (#62): read paths only serve 'visible' rows, an open report // can hide content while it is reviewed, and a resolved decision can remove it. - moderationState String @default("visible") // visible, hidden, removed - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + moderationState String @default("visible") // visible, hidden, removed + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([creatorId, status, createdAt]) @@index([fromUserId, createdAt]) @@ -304,17 +311,17 @@ model StellarAsset { } model Session { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - token String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique device String? userAgent String? ipAddress String? - lastActivity DateTime @default(now()) + lastActivity DateTime @default(now()) expiresAt DateTime revokedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId, revokedAt, expiresAt]) @@index([lastActivity]) @@ -389,15 +396,14 @@ model Webhook { creatorId String creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) - url String - events String[] // tip.created, tip.confirmed, tip.failed, payout.completed - secret String // HMAC secret for signature verification - previousSecret String? // Previous secret during rotation period + url String + events String[] // Versioned event subscriptions, e.g. tip.created + secret String // HMAC secret for signature verification + previousSecret String? // Previous secret during rotation period secretRotatedAt DateTime? // When secret was last rotated - active Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt webhookEvents WebhookEvent[] webhookNonces WebhookNonce[] @@ -410,14 +416,14 @@ model Webhook { } model WebhookNonce { - id String @id @default(cuid()) + id String @id @default(cuid()) webhookId String - webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) - + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + nonce String @unique // The nonce value from webhook request - timestamp BigInt // Unix timestamp of the webhook request + timestamp BigInt // Unix timestamp of the webhook request expiresAt DateTime // When this nonce can be cleaned up - + createdAt DateTime @default(now()) @@index([webhookId, timestamp]) @@ -438,7 +444,7 @@ model WebhookEvent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + @@index([webhookId, status, createdAt(sort: Desc)]) @@index([webhookId]) @@index([status]) @@ -453,8 +459,8 @@ model WebhookEvent { // A payment processed by an external provider (Stripe et al). Raw card data is // never stored — only provider references and tokenized intent ids. model Payment { - id String @id @default(cuid()) - idempotencyKey String @unique + id String @id @default(cuid()) + idempotencyKey String @unique userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -490,9 +496,9 @@ model PaymentEvent { paymentId String payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) - type String // payment.created, payment.succeeded, payment.failed, refund.created, chargeback.created, webhook.received - actor String? // user id, "system" or "provider" - data Json? + type String // payment.created, payment.succeeded, payment.failed, refund.created, chargeback.created, webhook.received + actor String? // user id, "system" or "provider" + data Json? // Provider event id — unique so webhook redelivery is idempotent. providerEventId String? @unique @@ -528,11 +534,11 @@ model Refund { payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) idempotencyKey String @unique - amount Float - currency String @default("USD") - reason String? - status String @default("pending") // pending, succeeded, failed - providerRef String? + amount Float + currency String @default("USD") + reason String? + status String @default("pending") // pending, succeeded, failed + providerRef String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -555,7 +561,7 @@ model WalletFlag { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + @@index([resolved, address]) @@index([resolved, createdAt(sort: Desc)]) @@index([resolved]) @@ -599,9 +605,9 @@ enum PayoutStatus { } model Payout { - id String @id @default(cuid()) - creatorId String - creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + creatorId String + creator Creator @relation(fields: [creatorId], references: [id], onDelete: Cascade) amount Float status PayoutStatus @default(pending) @@ -609,8 +615,8 @@ model Payout { walletAddress String errorMessage String? - retryCount Int @default(0) - nextRetryAt DateTime? + retryCount Int @default(0) + nextRetryAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -624,6 +630,7 @@ model Payout { @@index([status, nextRetryAt], map: "idx_payout_status_nextRetryAt") @@index([transactionHash]) } + // Media attached to tip messages (#64). The row exists before the bytes do (it is // the upload reservation), becomes `ready` once the file has been verified and // scanned, and is attached to a tip by setting `tipId` at tip creation time. @@ -647,7 +654,7 @@ model TipMedia { height Int? durationSeconds Float? // Derived renditions (preview/optimized/thumbnail) with their keys and sizes. - derivatives Json @default("[]") + derivatives Json @default("[]") processingStatus String @default("pending") // pending, done, skipped, failed processingError String? @@ -683,6 +690,7 @@ model MediaQuota { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + // --------------------------------------------------------------------------- // Team / organization support (#66) // @@ -821,8 +829,8 @@ model TeamContribution { type String // development | design | content | marketing | community | operations | manual description String? - amount Float @default(0) - weight Int @default(1) + amount Float @default(0) + weight Int @default(1) reference String? occurredAt DateTime @default(now()) @@ -896,8 +904,8 @@ model TeamPayout { requestedBy CreatorTeamMember? @relation(fields: [requestedById], references: [id], onDelete: SetNull) processedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([teamId, status], map: "idx_teamPayout_team_status") @@index([teamId, createdAt(sort: Desc)], map: "idx_teamPayout_team_createdAt") @@ -911,12 +919,12 @@ model TeamAuditLog { teamId String team CreatorTeam @relation(fields: [teamId], references: [id], onDelete: Cascade) - action String // team.created, member.added, member.role_changed, revenue.distributed, ... - actorId String? - actorUserId String? - targetMemberId String? + action String // team.created, member.added, member.role_changed, revenue.distributed, ... + actorId String? + actorUserId String? + targetMemberId String? targetCreatorId String? - metadata Json? + metadata Json? createdAt DateTime @default(now()) @@ -998,10 +1006,10 @@ model Report { status String @default("reported") // reported, investigating, resolved, dismissed decision String @default("none") // none, approved, denied - priority String @default("normal") // low, normal, high, urgent + priority String @default("normal") // low, normal, high, urgent // Numeric mirror of `priority` so the queue can order by urgency in SQL // instead of sorting in the application. - priorityRank Int @default(2) + priorityRank Int @default(2) resolution String? resolvedBy String? diff --git a/src/__tests__/integration/tip-flow.integration.test.ts b/src/__tests__/integration/tip-flow.integration.test.ts index 1617859..d2614ba 100644 --- a/src/__tests__/integration/tip-flow.integration.test.ts +++ b/src/__tests__/integration/tip-flow.integration.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { PrismaClient } from '@prisma/client'; import { PaymentService } from '../../domains/payments/payment.service'; @@ -9,10 +10,11 @@ let paymentService: PaymentService; let userService: UserService; let payoutService: PayoutService; +// Test data +const createdUserIds: string[] = []; let testUserId: string; let testCreatorId: string; let testCreatorUserId: string; - const isDbAvailable = Boolean(process.env.DATABASE_URL); /** * `$connect()` resolves lazily in Prisma 5, so it cannot be used as a @@ -27,10 +29,12 @@ describe.skipIf(!isDbAvailable)('Tip Flow Integration Tests', () => { console.warn('DATABASE_URL not set, skipping integration tests'); return; } + try { prisma = new PrismaClient(); await prisma.$queryRaw`SELECT 1`; dbReachable = true; + paymentService = new PaymentService(prisma); userService = new UserService(prisma); payoutService = new PayoutService(prisma); @@ -47,10 +51,10 @@ describe.skipIf(!isDbAvailable)('Tip Flow Integration Tests', () => { } try { // Clean up test data - await prisma.tip.deleteMany({}); - await prisma.wallet.deleteMany({}); - await prisma.creator.deleteMany({}); - await prisma.user.deleteMany({}); + await prisma.tip.deleteMany({ where: { fromUserId: { in: createdUserIds } } }); + await prisma.wallet.deleteMany({ where: { userId: { in: createdUserIds } } }); + await prisma.creator.deleteMany({ where: { userId: { in: createdUserIds } } }); + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); await prisma.$disconnect(); } catch { // ignore cleanup errors on disconnected DB @@ -72,6 +76,7 @@ describe.skipIf(!isDbAvailable)('Tip Flow Integration Tests', () => { }, }); testUserId = fanUser.id; + createdUserIds.push(fanUser.id); const creatorUser = await prisma.user.create({ data: { @@ -82,26 +87,29 @@ describe.skipIf(!isDbAvailable)('Tip Flow Integration Tests', () => { }, }); testCreatorUserId = creatorUser.id; + createdUserIds.push(creatorUser.id); - // Create creator profile + // Create verified creator profile const creator = await prisma.creator.create({ data: { userId: testCreatorUserId, username: `creator-${Date.now()}`, displayName: 'Test Creator', isPublic: true, - }, - }); - testCreatorId = creator.id; - - // Link verified wallet to fan - await prisma.wallet.create({ - data: { - userId: testUserId, - publicKey: `GBBD47AB2EB00E041B61C1B7AD184E687E24658D52EDFFDD118F5E6221D60E${Math.random().toString().slice(2, 4)}`, verified: true, }, }); + +testCreatorId = creator.id; + +// Link verified wallet to fan + await prisma.wallet.create({ + data: { + userId: testUserId, + publicKey: `TEST-${randomUUID()}`, + verified: true, + }, + }); }); describe('Complete tip flow', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 5afed1f..97f5015 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -124,9 +124,9 @@ export const EnvSchemaObject = z.object({ NODE_ENV: z.enum(['development', 'staging', 'production', 'test']).default('development'), PORT: int(3000, { min: 1, max: 65_535 }), HTTP2_ENABLED: bool(false), - RESPONSE_COMPRESSION_ENABLED: bool(true), - RESPONSE_CACHE_CONTROL: z.string().default('private, no-cache'), LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), + RESPONSE_CACHE_CONTROL: z.string().default('private, no-cache'), + RESPONSE_COMPRESSION_ENABLED: bool(true), // Max time (ms) to wait for in-flight requests to drain on SIGTERM/SIGINT (#23). SHUTDOWN_TIMEOUT_MS: int(30_000, { min: 1_000, max: 300_000 }), // Swagger server host (e.g. api.example.com). Empty = localhost. @@ -134,6 +134,10 @@ export const EnvSchemaObject = z.object({ // ── Database ─────────────────────────────────────────────────────────── DATABASE_URL: optionalString, + DATABASE_READ_REPLICA_URLS: optionalString, + DB_REPLICA_ENABLED: bool(false), + DB_REPLICA_LAG_TOLERANCE_SECONDS: int(5, { min: 0, max: 300 }), + DB_REPLICA_HEALTHCHECK_INTERVAL_MS: int(10_000, { min: 1_000, max: 600_000 }), DB_POOL_MIN: int(2, { min: 1, max: 100 }), DB_POOL_MAX: int(20, { min: 1, max: 100 }), DB_CONNECTION_TIMEOUT_MS: int(5_000, { min: 100, max: 60_000 }), @@ -150,62 +154,6 @@ export const EnvSchemaObject = z.object({ DB_LEAK_DETECTION_TIMEOUT_MS: int(30_000, { min: 1_000, max: 300_000 }), DB_CIRCUIT_BREAKER_FAILURES: int(5, { min: 1, max: 100 }), DB_CIRCUIT_BREAKER_RESET_MS: int(10_000, { min: 100, max: 600_000 }), - // PostgreSQL transport security. Production/staging require TLS; local - // development may opt into `prefer` for self-signed databases. - DB_SSL_MODE: z - .enum(['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']) - .default('prefer'), - DB_SSL_REJECT_UNAUTHORIZED: bool(true), - DB_SSL_CA: optionalString, - DB_SSL_CERT: optionalString, - DB_SSL_KEY: optionalString, - DB_SSL_SERVERNAME: optionalString, - // Backup verification is opt-in so an API process never restores data by - // accident; the dedicated worker/CI job enables it explicitly. - BACKUP_VERIFICATION_ENABLED: bool(false), - BACKUP_VERIFICATION_CRON: z.string().default('0 3 * * 0'), - BACKUP_VERIFICATION_TIMEOUT_MS: int(300_000, { min: 1_000, max: 3_600_000 }), - BACKUP_VERIFICATION_MAX_RESTORE_MS: int(120_000, { min: 1_000, max: 3_600_000 }), - BACKUP_DIRECTORY: z.string().default('./private/backups'), - BACKUP_SECONDARY_DIRECTORY: z.string().default('./private/backups-secondary'), - BACKUP_ENCRYPTION_KEY: optionalString, - BACKUP_VERIFICATION_HISTORY_PATH: z.string().default('./private/backup-verification.jsonl'), -export const EnvSchemaObject = z - .object({ - // ── Runtime ──────────────────────────────────────────────────────────── - NODE_ENV: z.enum(['development', 'staging', 'production', 'test']).default('development'), - PORT: int(3000, { min: 1, max: 65_535 }), - HTTP2_ENABLED: bool(false), - LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), - RESPONSE_CACHE_CONTROL: z.string().default('private, no-cache'), - RESPONSE_COMPRESSION_ENABLED: bool(true), - // Max time (ms) to wait for in-flight requests to drain on SIGTERM/SIGINT (#23). - SHUTDOWN_TIMEOUT_MS: int(30_000, { min: 1_000, max: 300_000 }), - // Swagger server host (e.g. api.example.com). Empty = localhost. - API_HOST: optionalString, - - // ── Database ─────────────────────────────────────────────────────────── - DATABASE_URL: optionalString, - DATABASE_READ_REPLICA_URLS: optionalString, - DB_REPLICA_ENABLED: bool(false), - DB_REPLICA_LAG_TOLERANCE_SECONDS: int(5, { min: 0, max: 300 }), - DB_REPLICA_HEALTHCHECK_INTERVAL_MS: int(10_000, { min: 1_000, max: 600_000 }), - DB_POOL_MIN: int(2, { min: 1, max: 100 }), - DB_POOL_MAX: int(20, { min: 1, max: 100 }), - DB_CONNECTION_TIMEOUT_MS: int(5_000, { min: 100, max: 60_000 }), - DB_IDLE_TIMEOUT_MS: int(30_000, { min: 0, max: 300_000 }), - DB_MAX_LIFETIME_MS: int(1_800_000, { min: 1_000, max: 3_600_000 }), - DB_STATEMENT_TIMEOUT_MS: int(10_000, { min: 0, max: 600_000 }), - DB_SLOW_QUERY_THRESHOLD_MS: int(200, { min: 1, max: 60_000 }), - DB_LOG_QUERIES: bool(false), - // Read-query result cache (1-5 minutes, per issue #12) - DB_QUERY_CACHE_TTL_MS: int(60_000, { min: 1_000, max: 300_000 }), - DB_QUERY_CACHE_MAX_TTL_MS: int(300_000, { min: 1_000, max: 600_000 }), - DB_QUERY_CACHE_MAX_ENTRIES: int(1_000, { min: 10, max: 100_000 }), - DB_QUERY_CACHE_ENABLED: bool(true), - DB_LEAK_DETECTION_TIMEOUT_MS: int(30_000, { min: 1_000, max: 300_000 }), - DB_CIRCUIT_BREAKER_FAILURES: int(5, { min: 1, max: 100 }), - DB_CIRCUIT_BREAKER_RESET_MS: int(10_000, { min: 100, max: 600_000 }), // ── Circuit breakers (external services) ────────────────────────────── CIRCUIT_BREAKER_FAILURE_THRESHOLD: num(0.5, { min: 0, max: 1 }), @@ -281,38 +229,38 @@ export const EnvSchemaObject = z VERIFICATION_DOCUMENT_STORAGE_PATH: z.string().default('./private/verification-documents'), SENDGRID_API_KEY: optionalString, EMAIL_FROM: optionalEmail, - // ── Email ────────────────────────────────────────────────────────────── - FRONTEND_URL: z.string().default('http://localhost:3000'), - VERIFICATION_DOCUMENT_STORAGE_PATH: z.string().default('./private/verification-documents'), - SENDGRID_API_KEY: optionalString, - EMAIL_FROM: optionalEmail, - - // ── HTTP surface ─────────────────────────────────────────────────────── - RESPONSE_CACHE_CONTROL: z.string().default('private, no-cache'), - // Reverse proxy trust: false | number of hops | comma separated CIDR list. - TRUST_PROXY: z.string().default('false'), - CORS_ORIGINS: optionalString, - CORS_CREDENTIALS: bool(true), - CORS_MAX_AGE: int(600, { min: 0, max: 86_400 }), - - // ── Rate limiting (see src/config/rate-limit.ts) ─────────────────────── - RATE_LIMIT_ENABLED: bool(true), - RATE_LIMIT_STORE: z.enum(['memory', 'redis']).default('memory'), - RATE_LIMIT_PUBLIC_MAX: int(100, { min: 1, max: 100_000 }), - RATE_LIMIT_PUBLIC_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), - RATE_LIMIT_AUTHENTICATED_MAX: int(300, { min: 1, max: 100_000 }), - RATE_LIMIT_AUTHENTICATED_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), - RATE_LIMIT_SENSITIVE_MAX: int(10, { min: 1, max: 1_000 }), - RATE_LIMIT_SENSITIVE_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), - - // ── API versioning ───────────────────────────────────────────────────── - SUPPORTED_API_VERSIONS: z.string().default('1,2'), - API_V1_SUNSET_DATE: z.string().optional(), - - // ── GraphQL ──────────────────────────────────────────────────────────── - GRAPHQL_ENABLED: bool(true), - GRAPHQL_MAX_DEPTH: int(10, { min: 1, max: 100 }), - GRAPHQL_MAX_COMPLEXITY: int(1_000, { min: 1, max: 100_000 }), + // ── Email ────────────────────────────────────────────────────────────── + FRONTEND_URL: z.string().default('http://localhost:3000'), + VERIFICATION_DOCUMENT_STORAGE_PATH: z.string().default('./private/verification-documents'), + SENDGRID_API_KEY: optionalString, + EMAIL_FROM: optionalEmail, + + // ── HTTP surface ─────────────────────────────────────────────────────── + RESPONSE_CACHE_CONTROL: z.string().default('private, no-cache'), + // Reverse proxy trust: false | number of hops | comma separated CIDR list. + TRUST_PROXY: z.string().default('false'), + CORS_ORIGINS: optionalString, + CORS_CREDENTIALS: bool(true), + CORS_MAX_AGE: int(600, { min: 0, max: 86_400 }), + + // ── Rate limiting (see src/config/rate-limit.ts) ─────────────────────── + RATE_LIMIT_ENABLED: bool(true), + RATE_LIMIT_STORE: z.enum(['memory', 'redis']).default('memory'), + RATE_LIMIT_PUBLIC_MAX: int(100, { min: 1, max: 100_000 }), + RATE_LIMIT_PUBLIC_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), + RATE_LIMIT_AUTHENTICATED_MAX: int(300, { min: 1, max: 100_000 }), + RATE_LIMIT_AUTHENTICATED_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), + RATE_LIMIT_SENSITIVE_MAX: int(10, { min: 1, max: 1_000 }), + RATE_LIMIT_SENSITIVE_WINDOW_MS: int(60_000, { min: 1_000, max: 3_600_000 }), + + // ── API versioning ───────────────────────────────────────────────────── + SUPPORTED_API_VERSIONS: z.string().default('1,2'), + API_V1_SUNSET_DATE: z.string().optional(), + + // ── GraphQL ──────────────────────────────────────────────────────────── + GRAPHQL_ENABLED: bool(true), + GRAPHQL_MAX_DEPTH: int(10, { min: 1, max: 100 }), + GRAPHQL_MAX_COMPLEXITY: int(1_000, { min: 1, max: 100_000 }), // ── Feature flags (see src/config/features.ts) ───────────────────────── // Defaults are environment-aware; src/config/features.ts resolves them and diff --git a/src/domains/admin/admin.routes.ts b/src/domains/admin/admin.routes.ts index 5da5d3e..65731c0 100644 --- a/src/domains/admin/admin.routes.ts +++ b/src/domains/admin/admin.routes.ts @@ -15,13 +15,17 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin }, async (request, reply) => { try { - const result = await adminService.bulkVerifyCreators(request.user?.userId ?? '', request.body.creatorIds); + const result = await adminService.bulkVerifyCreators( + request.user?.userId ?? '', + request.body.creatorIds + ); return reply.code(202).send(formatSuccess(result)); } catch (error) { - if (error instanceof AppError) return reply.code(error.statusCode).send(formatError(error.message, error.code)); + if (error instanceof AppError) + return reply.code(error.statusCode).send(formatError(error.message, error.code)); throw error; } - }, + } ); // POST /api/v1/admin/wallets/:address/flag - Flag wallet @@ -30,10 +34,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - params: { type: 'object', properties: { @@ -83,10 +83,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - params: { type: 'object', properties: { @@ -126,10 +122,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - params: { type: 'object', properties: { @@ -179,10 +171,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - params: { type: 'object', properties: { @@ -222,10 +210,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - response: { 200: { type: 'null', description: 'Moderation queue' }, 401: { type: 'null', description: 'Unauthorized' }, @@ -240,7 +224,11 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): const query = request.query as { page?: string; pageSize?: string; limit?: string }; const page = query?.page ? parseInt(query.page) : 1; - const pageSize = query?.pageSize ? parseInt(query.pageSize) : query?.limit ? parseInt(query.limit) : 20; + const pageSize = query?.pageSize + ? parseInt(query.pageSize) + : query?.limit + ? parseInt(query.limit) + : 20; const result = await adminService.getModerationQueue(page, pageSize); reply.send(formatSuccess(result)); @@ -262,10 +250,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - response: { 200: { type: 'null', description: 'Cache statistics' }, 401: { type: 'null', description: 'Unauthorized' }, @@ -298,10 +282,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - response: { 200: { type: 'null', description: 'Cache cleared' }, 401: { type: 'null', description: 'Unauthorized' }, @@ -334,10 +314,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - body: { type: 'object', required: ['key'], @@ -378,14 +354,14 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - body: { type: 'object', properties: { - type: { type: 'string', enum: ['creators', 'trending', 'analytics', 'all'], description: 'Type of cache warming' }, + type: { + type: 'string', + enum: ['creators', 'trending', 'analytics', 'all'], + description: 'Type of cache warming', + }, }, }, response: { @@ -438,10 +414,6 @@ export const registerAdminRoutes = (app: FastifyInstance, prisma: PrismaClient): { preHandler: requireAdmin, schema: { - - - - response: { 200: { type: 'null', description: 'Cache statistics reset' }, 401: { type: 'null', description: 'Unauthorized' }, diff --git a/src/domains/analytics/analytics.service.ts b/src/domains/analytics/analytics.service.ts index 44cfc33..23d57a0 100644 --- a/src/domains/analytics/analytics.service.ts +++ b/src/domains/analytics/analytics.service.ts @@ -24,7 +24,8 @@ export class AnalyticsService extends BaseService { > { return this.executeWithLogging('analytics.earningsOverTime', async () => { const cacheKey = `analytics:earnings:${creatorId}:${days}:${granularity}`; - const cached = queryCache.get<{ date: string; earnings: number; tipCount: number }[]>(cacheKey); + const cached = + queryCache.get<{ date: string; earnings: number; tipCount: number }[]>(cacheKey); if (cached) { return cached; } @@ -110,7 +111,10 @@ export class AnalyticsService extends BaseService { > { return this.executeWithLogging('analytics.topSupporters', async () => { const cacheKey = `analytics:topSupporters:${creatorId}:${limit}`; - const cached = queryCache.get<{ userId: string; totalAmount: number; tipCount: number; lastTipDate: string }[]>(cacheKey); + const cached = + queryCache.get< + { userId: string; totalAmount: number; tipCount: number; lastTipDate: string }[] + >(cacheKey); if (cached) { return cached; } @@ -236,9 +240,7 @@ export class AnalyticsService extends BaseService { }), ]); - const growthRate = previousTips > 0 - ? ((recentTips - previousTips) / previousTips) * 100 - : 0; + const growthRate = previousTips > 0 ? ((recentTips - previousTips) / previousTips) * 100 : 0; // Find peak day const tipsByDay = await this.prisma.tip.findMany({ @@ -349,4 +351,3 @@ export class AnalyticsService extends BaseService { queryCache.invalidateTags([`analytics:creator:${creatorId}`]); } } - diff --git a/src/domains/creators/verification.service.test.ts b/src/domains/creators/verification.service.test.ts index 27eb1c7..bf29d1e 100644 --- a/src/domains/creators/verification.service.test.ts +++ b/src/domains/creators/verification.service.test.ts @@ -2,12 +2,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { PrismaClient } from '@prisma/client'; import { ConflictError, ValidationError } from '../../utils/errors'; -vi.mock('../notifications/email', () => ({ enqueueEmail: vi.fn().mockResolvedValue('email-job-1') })); +const { dispatchEvent } = vi.hoisted(() => ({ + dispatchEvent: vi.fn(), +})); + +vi.mock('../webhooks/webhook.service', () => ({ + WebhookService: vi.fn(() => ({ + dispatchEvent, + })), +})); + +vi.mock('../notifications/email', () => ({ + enqueueEmail: vi.fn().mockResolvedValue('email-job-1'), +})); + vi.mock('./verification-documents', () => ({ getVerificationDocumentPath: vi.fn((key: string) => `private/${key}`), removeVerificationDocuments: vi.fn().mockResolvedValue(undefined), storeVerificationDocuments: vi.fn().mockResolvedValue([ - { storageKey: 'request-1/id.png', filename: 'id.png', contentType: 'image/png', size: 5 }, + { + storageKey: 'request-1/id.png', + filename: 'id.png', + contentType: 'image/png', + size: 5, + }, ]), })); @@ -43,7 +61,9 @@ function createPrismaMock() { updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, }; - const transaction = vi.fn(async (callback: (client: typeof tx) => Promise) => callback(tx)); + const transaction = vi.fn(async (callback: (client: typeof tx) => Promise) => + callback(tx) + ); const mock = { creator: { findUnique: vi.fn().mockResolvedValue(creator), @@ -71,15 +91,19 @@ describe('VerificationService', () => { }); it('requires a verified email and at least one identity document', async () => { - await expect(service.submitRequest('user-1', { documents: [] })).rejects.toBeInstanceOf(ValidationError); + await expect(service.submitRequest('user-1', { documents: [] })).rejects.toBeInstanceOf( + ValidationError + ); state.mock.creator.findUnique.mockResolvedValue({ ...creator, user: { ...creator.user, verified: false }, }); - await expect(service.submitRequest('user-1', { - documents: [{ filename: 'id.png', contentType: 'image/png', data: Buffer.from('image') }], - })).rejects.toThrow('Verify your email'); + await expect( + service.submitRequest('user-1', { + documents: [{ filename: 'id.png', contentType: 'image/png', data: Buffer.from('image') }], + }) + ).rejects.toThrow('Verify your email'); expect(storeVerificationDocuments).not.toHaveBeenCalled(); }); @@ -90,62 +114,95 @@ describe('VerificationService', () => { }); expect(result).toEqual(request); - expect(state.tx.creatorVerificationRequest.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ creatorId: 'creator-1', statement: 'I am this creator' }), - })); - expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ action: 'submitted', actorId: 'user-1' }), - })); - expect(enqueueEmail).toHaveBeenCalledWith(expect.objectContaining({ - to: 'creator@example.com', - template: 'creator-verification', - data: expect.objectContaining({ status: 'submitted' }), - })); + expect(state.tx.creatorVerificationRequest.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ creatorId: 'creator-1', statement: 'I am this creator' }), + }) + ); + expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ action: 'submitted', actorId: 'user-1' }), + }) + ); + expect(enqueueEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'creator@example.com', + template: 'creator-verification', + data: expect.objectContaining({ status: 'submitted' }), + }) + ); }); it('cleans up uploaded files and reports a conflict for concurrent submissions', async () => { - state.transaction.mockRejectedValueOnce(Object.assign(new Error('unique constraint'), { code: 'P2002' })); + state.transaction.mockRejectedValueOnce( + Object.assign(new Error('unique constraint'), { code: 'P2002' }) + ); - await expect(service.submitRequest('user-1', { - documents: [{ filename: 'id.png', contentType: 'image/png', data: Buffer.from('image') }], - })).rejects.toBeInstanceOf(ConflictError); + await expect( + service.submitRequest('user-1', { + documents: [{ filename: 'id.png', contentType: 'image/png', data: Buffer.from('image') }], + }) + ).rejects.toBeInstanceOf(ConflictError); expect(removeVerificationDocuments).toHaveBeenCalledWith(expect.any(String)); }); - it.each(['approved', 'rejected'] as const)('records a %s decision and notifies the creator', async (decision) => { - await service.decideRequest('request-1', 'admin-1', decision, 'Reviewed'); - - expect(state.tx.creatorVerificationRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - id: 'request-1', - status: 'submitted', - expiresAt: { gt: expect.any(Date) }, - }), - data: expect.objectContaining({ status: decision, reviewerId: 'admin-1', reviewReason: 'Reviewed' }), - })); - expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ action: decision, actorId: 'admin-1', reason: 'Reviewed' }), - })); - expect(enqueueEmail).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: decision, reason: 'Reviewed' }), - })); - expect(state.tx.creator.update).toHaveBeenCalledTimes(decision === 'approved' ? 1 : 0); - }); + it.each(['approved', 'rejected'] as const)( + 'records a %s decision and notifies the creator', + async (decision) => { + await service.decideRequest('request-1', 'admin-1', decision, 'Reviewed'); + + expect(state.tx.creatorVerificationRequest.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'request-1', + status: 'submitted', + expiresAt: { gt: expect.any(Date) }, + }), + data: expect.objectContaining({ + status: decision, + reviewerId: 'admin-1', + reviewReason: 'Reviewed', + }), + }) + ); + expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: decision, + actorId: 'admin-1', + reason: 'Reviewed', + }), + }) + ); + expect(enqueueEmail).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: decision, reason: 'Reviewed' }), + }) + ); + expect(state.tx.creator.update).toHaveBeenCalledTimes(decision === 'approved' ? 1 : 0); + } + ); it('does not allow two admins to decide the same request', async () => { state.tx.creatorVerificationRequest.updateMany.mockResolvedValueOnce({ count: 0 }); - await expect(service.decideRequest('request-1', 'admin-1', 'approved')).rejects.toBeInstanceOf(ConflictError); + await expect(service.decideRequest('request-1', 'admin-1', 'approved')).rejects.toBeInstanceOf( + ConflictError + ); expect(state.tx.creatorVerificationEvent.create).not.toHaveBeenCalled(); expect(enqueueEmail).not.toHaveBeenCalled(); }); it('expires overdue requests before allowing an admin decision', async () => { vi.spyOn(service, 'expireDueRequests').mockResolvedValue(1); - state.mock.creatorVerificationRequest.findUnique.mockResolvedValue({ ...request, status: 'expired' }); + state.mock.creatorVerificationRequest.findUnique.mockResolvedValue({ + ...request, + status: 'expired', + }); - await expect(service.decideRequest('request-1', 'admin-1', 'approved')) - .rejects.toThrow('Verification request is no longer pending'); + await expect(service.decideRequest('request-1', 'admin-1', 'approved')).rejects.toThrow( + 'Verification request is no longer pending' + ); expect(service.expireDueRequests).toHaveBeenCalledWith(expect.any(Date)); expect(state.tx.creator.update).not.toHaveBeenCalled(); expect(enqueueEmail).not.toHaveBeenCalled(); @@ -158,43 +215,62 @@ describe('VerificationService', () => { verifiedAt: new Date('2026-01-01T00:00:00.000Z'), }); - await expect(service.unverifyCreator('creator-1', 'admin-1', 'Policy violation')) - .resolves.toEqual({ verified: false }); - expect(state.tx.creator.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'creator-1' }, - data: { verified: false, verifiedAt: null, verifiedUntil: null }, - })); - expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ action: 'revoked', actorId: 'admin-1', reason: 'Policy violation' }), - })); - expect(enqueueEmail).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'revoked' }), - })); + await expect( + service.unverifyCreator('creator-1', 'admin-1', 'Policy violation') + ).resolves.toEqual({ verified: false }); + expect(state.tx.creator.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'creator-1' }, + data: { verified: false, verifiedAt: null, verifiedUntil: null }, + }) + ); + expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'revoked', + actorId: 'admin-1', + reason: 'Policy violation', + }), + }) + ); + expect(enqueueEmail).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'revoked' }), + }) + ); }); it('expires due requests, removes expired verification, and notifies the creator', async () => { - state.mock.creatorVerificationRequest.findMany.mockResolvedValue([{ - ...request, - status: 'approved', - expiresAt: new Date('2025-01-01T00:00:00.000Z'), - creator: { ...creator, user: creator.user }, - }]); + state.mock.creatorVerificationRequest.findMany.mockResolvedValue([ + { + ...request, + status: 'approved', + expiresAt: new Date('2025-01-01T00:00:00.000Z'), + creator: { ...creator, user: creator.user }, + }, + ]); await expect(service.expireDueRequests(new Date('2026-01-01T00:00:00.000Z'))).resolves.toBe(1); - expect(state.tx.creatorVerificationRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - data: { status: 'expired', reviewedAt: new Date('2026-01-01T00:00:00.000Z') }, - })); + expect(state.tx.creatorVerificationRequest.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { status: 'expired', reviewedAt: new Date('2026-01-01T00:00:00.000Z') }, + }) + ); expect(state.tx.creator.updateMany).toHaveBeenCalled(); - expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ action: 'expired' }), - })); - expect(enqueueEmail).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ status: 'expired' }), - })); + expect(state.tx.creatorVerificationEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ action: 'expired' }), + }) + ); + expect(enqueueEmail).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'expired' }), + }) + ); }); it('cannot verify a creator outside the approved request workflow', async () => { await expect(service.verifyCreator('creator-1')).rejects.toBeInstanceOf(ValidationError); expect(state.mock.creator.update).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/domains/creators/verification.service.ts b/src/domains/creators/verification.service.ts index b1bed09..d9a1f7c 100644 --- a/src/domains/creators/verification.service.ts +++ b/src/domains/creators/verification.service.ts @@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client'; import { BaseService } from '../../services/base.service'; import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors'; import { logger } from '../../utils/logger'; +import { WebhookService } from '../webhooks/webhook.service'; import { enqueueEmail } from '../notifications/email'; import { getVerificationDocumentPath, @@ -28,7 +29,7 @@ export class VerificationService extends BaseService { async submitRequest( userId: string, - input: { statement?: string; documents: VerificationDocumentInput[] }, + input: { statement?: string; documents: VerificationDocumentInput[] } ) { return this.executeWithLogging('creator.verification.submit', async () => { await this.expireDueRequests(); @@ -60,7 +61,17 @@ export class VerificationService extends BaseService { expiresAt: new Date(Date.now() + PENDING_REQUEST_TTL_MS), documents: { create: documents }, }, - include: { documents: { select: { id: true, filename: true, contentType: true, size: true, createdAt: true } } }, + include: { + documents: { + select: { + id: true, + filename: true, + contentType: true, + size: true, + createdAt: true, + }, + }, + }, }); await tx.creatorVerificationEvent.create({ data: { @@ -88,13 +99,18 @@ export class VerificationService extends BaseService { async getRequestHistory(userId: string) { return this.executeWithLogging('creator.verification.history', async () => { await this.expireDueRequests(); - const creator = await this.prisma.creator.findUnique({ where: { userId }, select: { id: true } }); + const creator = await this.prisma.creator.findUnique({ + where: { userId }, + select: { id: true }, + }); if (!creator) throw new NotFoundError('Creator'); return this.prisma.creatorVerificationRequest.findMany({ where: { creatorId: creator.id }, orderBy: { createdAt: 'desc' }, include: { - documents: { select: { id: true, filename: true, contentType: true, size: true, createdAt: true } }, + documents: { + select: { id: true, filename: true, contentType: true, size: true, createdAt: true }, + }, events: { orderBy: { createdAt: 'asc' } }, }, }); @@ -115,12 +131,20 @@ export class VerificationService extends BaseService { take: safePageSize, include: { creator: { include: { user: { select: { id: true, email: true, name: true } } } }, - documents: { select: { id: true, filename: true, contentType: true, size: true, createdAt: true } }, + documents: { + select: { id: true, filename: true, contentType: true, size: true, createdAt: true }, + }, }, }), this.prisma.creatorVerificationRequest.count({ where }), ]); - return { items, total, page: safePage, pageSize: safePageSize, totalPages: Math.ceil(total / safePageSize) }; + return { + items, + total, + page: safePage, + pageSize: safePageSize, + totalPages: Math.ceil(total / safePageSize), + }; }); } @@ -128,17 +152,20 @@ export class VerificationService extends BaseService { requestId: string, reviewerId: string, decision: VerificationDecision, - reason?: string, + reason?: string ) { return this.executeWithLogging(`creator.verification.${decision}`, async () => { const now = new Date(); await this.expireDueRequests(now); const request = await this.prisma.creatorVerificationRequest.findUnique({ where: { id: requestId }, - include: { creator: { include: { user: { select: { id: true, email: true, name: true } } } } }, + include: { + creator: { include: { user: { select: { id: true, email: true, name: true } } } }, + }, }); if (!request) throw new NotFoundError('Verification request'); - if (request.status !== 'submitted') throw new ConflictError('Verification request is no longer pending'); + if (request.status !== 'submitted') + throw new ConflictError('Verification request is no longer pending'); const verifiedUntil = new Date(now.getTime() + VERIFICATION_DURATION_MS); const result = await this.prisma.$transaction(async (tx) => { @@ -152,7 +179,8 @@ export class VerificationService extends BaseService { ...(decision === 'approved' ? { expiresAt: verifiedUntil } : {}), }, }); - if (changed.count === 0) throw new ConflictError('Verification request is no longer pending'); + if (changed.count === 0) + throw new ConflictError('Verification request is no longer pending'); if (decision === 'approved') { await tx.creator.update({ @@ -167,20 +195,41 @@ export class VerificationService extends BaseService { actorId: reviewerId, action: decision, reason: reason?.trim() || null, - metadata: decision === 'approved' ? { verifiedUntil: verifiedUntil.toISOString() } : undefined, + metadata: + decision === 'approved' ? { verifiedUntil: verifiedUntil.toISOString() } : undefined, }, }); return tx.creatorVerificationRequest.findUniqueOrThrow({ where: { id: requestId }, - include: { documents: { select: { id: true, filename: true, contentType: true, size: true, createdAt: true } } }, + include: { + documents: { + select: { id: true, filename: true, contentType: true, size: true, createdAt: true }, + }, + }, }); }); + + if (decision === 'approved') { + logger.info(`Creator verified: ${request.creatorId}`); + + await new WebhookService(this.prisma).dispatchEvent( + request.creatorId, + request.creatorId, + 'creator.verified', + { creatorId: request.creatorId } + ); + } + await this.notify(request.creator.user, decision, reason); return result; }); } - async unverifyCreator(creatorId: string, actorId: string, reason: string): Promise<{ verified: boolean }> { + async unverifyCreator( + creatorId: string, + actorId: string, + reason: string + ): Promise<{ verified: boolean }> { return this.executeWithLogging('creator.verification.revoke', async () => { const creator = await this.prisma.creator.findUnique({ where: { id: creatorId }, @@ -210,7 +259,9 @@ export class VerificationService extends BaseService { } async getVerificationDocument(documentId: string) { - const document = await this.prisma.creatorVerificationDocument.findUnique({ where: { id: documentId } }); + const document = await this.prisma.creatorVerificationDocument.findUnique({ + where: { id: documentId }, + }); if (!document) throw new NotFoundError('Verification document'); return { ...document, path: getVerificationDocumentPath(document.storageKey) }; } @@ -218,7 +269,9 @@ export class VerificationService extends BaseService { async expireDueRequests(now = new Date()): Promise { const expired = await this.prisma.creatorVerificationRequest.findMany({ where: { status: { in: ['submitted', 'approved'] }, expiresAt: { lte: now } }, - include: { creator: { include: { user: { select: { id: true, email: true, name: true } } } } }, + include: { + creator: { include: { user: { select: { id: true, email: true, name: true } } } }, + }, }); let count = 0; for (const request of expired) { @@ -250,7 +303,7 @@ export class VerificationService extends BaseService { private async notify( user: { id: string; email: string; name: string | null }, status: string, - reason?: string, + reason?: string ): Promise { try { await enqueueEmail({ @@ -261,7 +314,10 @@ export class VerificationService extends BaseService { eventType: `creator_verification.${status}`, }); } catch (error) { - logger.error({ error, userId: user.id, status }, 'Failed to enqueue creator verification email'); + logger.error( + { error, userId: user.id, status }, + 'Failed to enqueue creator verification email' + ); } } diff --git a/src/domains/payments/__tests__/tip-concurrency.test.ts b/src/domains/payments/__tests__/tip-concurrency.test.ts index 184615b..4ec42a6 100644 --- a/src/domains/payments/__tests__/tip-concurrency.test.ts +++ b/src/domains/payments/__tests__/tip-concurrency.test.ts @@ -27,6 +27,7 @@ const mockPrisma = { creator: { findUnique: vi.fn(), update: vi.fn() }, user: { findUnique: vi.fn() }, wallet: { findFirst: vi.fn() }, + stellarAsset: { findFirst: vi.fn() }, walletFlag: { findFirst: vi.fn() }, accountFreeze: { findFirst: vi.fn() }, webhook: { findMany: vi.fn() }, @@ -65,6 +66,8 @@ describe('createTip idempotency (#48)', () => { beforeEach(() => { vi.clearAllMocks(); service = new PaymentService(mockPrisma as any); + mockPrisma.webhook.findMany.mockResolvedValue([]); + mockPrisma.user.findUnique.mockResolvedValue({ id: userId }); mockPrisma.creator.findUnique.mockResolvedValue({ id: creatorId, @@ -72,9 +75,20 @@ describe('createTip idempotency (#48)', () => { isPublic: true, verified: true, }); - mockPrisma.wallet.findFirst.mockResolvedValue({ id: 'wallet-1', publicKey: 'GABC', verified: true }); + mockPrisma.wallet.findFirst.mockResolvedValue({ + id: 'wallet-1', + publicKey: 'GABC', + verified: true, + }); mockPrisma.walletFlag.findFirst.mockResolvedValue(null); mockPrisma.accountFreeze.findFirst.mockResolvedValue(null); + mockPrisma.stellarAsset.findFirst.mockResolvedValue({ + id: 'asset-usdc', + code: 'USDC', + issuer: 'GUSDCISSUER', + enabled: true, + priority: 100, + }); }); it('returns the original tip when an idempotency key is replayed', async () => { @@ -101,7 +115,9 @@ describe('createTip idempotency (#48)', () => { it('persists the idempotency key on first submission', async () => { mockPrisma.tip.findUnique.mockResolvedValue(null); - mockPrisma.tip.create.mockResolvedValue(makeTipRow({ id: 'tip-new', idempotencyKey: 'key-12345678' })); + mockPrisma.tip.create.mockResolvedValue( + makeTipRow({ id: 'tip-new', idempotencyKey: 'key-12345678' }) + ); await service.createTip(userId, { creatorId, amount: 25, idempotencyKey: 'key-12345678' }); @@ -202,6 +218,7 @@ describe('submitPaymentTransaction duplicate submission (#48)', () => { beforeEach(() => { vi.clearAllMocks(); service = new PaymentService(mockPrisma as any); + mockPrisma.webhook.findMany.mockResolvedValue([]); }); it('replays an already-stored transaction instead of submitting again', async () => { diff --git a/src/domains/payments/payment.service.test.ts b/src/domains/payments/payment.service.test.ts index f7209e2..fa18425 100644 --- a/src/domains/payments/payment.service.test.ts +++ b/src/domains/payments/payment.service.test.ts @@ -1,3 +1,9 @@ +import type { PrismaClient } from '@prisma/client'; +vi.mock('../webhooks/webhook.service', () => ({ + WebhookService: vi.fn(() => ({ dispatchEvent: publish })), +})); +const { publish } = vi.hoisted(() => ({ publish: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../../lib/queue', () => ({ stellarConfirmationQueue: { add: vi.fn() } })); import { describe, it, expect, beforeEach, vi } from 'vitest'; import { PaymentService } from './payment.service'; import { ValidationError, NotFoundError } from '../../utils/errors'; @@ -14,6 +20,9 @@ const mockPrisma = { wallet: { findFirst: vi.fn(), }, + stellarAsset: { + findFirst: vi.fn(), + }, walletFlag: { findFirst: vi.fn(), }, @@ -35,8 +44,17 @@ describe('PaymentService', () => { let paymentService: PaymentService; beforeEach(() => { - paymentService = new PaymentService(mockPrisma as any); + paymentService = new PaymentService(mockPrisma as unknown as PrismaClient); vi.clearAllMocks(); + + mockPrisma.stellarAsset.findFirst.mockResolvedValue({ + id: 'asset-usdc', + code: 'USDC', + issuer: 'GUSDCISSUER', + decimals: 7, + enabled: true, + priority: 100, + }); }); describe('createTip', () => { @@ -81,6 +99,12 @@ describe('PaymentService', () => { message: 'Great content!', }); + expect(publish).toHaveBeenCalledWith( + creatorId, + 'tip-123', + 'tip.created', + expect.objectContaining({ amount: 100 }) + ); expect(result.id).toBe('tip-123'); expect(result.amount).toBe(100); expect(result.status).toBe('pending'); @@ -168,9 +192,9 @@ describe('PaymentService', () => { }); mockPrisma.walletFlag.findFirst.mockResolvedValue({ id: 'flag-1' }); - await expect( - paymentService.createTip(userId, { creatorId, amount: 50 }) - ).rejects.toThrow(ValidationError); + await expect(paymentService.createTip(userId, { creatorId, amount: 50 })).rejects.toThrow( + ValidationError + ); expect(mockPrisma.tip.create).not.toHaveBeenCalled(); }); @@ -227,9 +251,9 @@ describe('PaymentService', () => { mockPrisma.walletFlag.findFirst.mockResolvedValue(null); mockPrisma.accountFreeze.findFirst.mockResolvedValue({ id: 'freeze-1' }); - await expect( - paymentService.createTip(userId, { creatorId, amount: 100 }) - ).rejects.toThrow(ValidationError); + await expect(paymentService.createTip(userId, { creatorId, amount: 100 })).rejects.toThrow( + ValidationError + ); expect(mockPrisma.tip.create).not.toHaveBeenCalled(); }); @@ -297,8 +321,34 @@ describe('PaymentService', () => { message: true, status: true, transactionHash: true, + assetCode: true, + assetIssuer: true, + assetDecimals: true, + moderationState: true, createdAt: true, updatedAt: true, + media: { + where: { status: 'ready' }, + orderBy: { attachedAt: 'asc' }, + select: { + id: true, + kind: true, + status: true, + mimeType: true, + fileName: true, + sizeBytes: true, + width: true, + height: true, + durationSeconds: true, + storageKey: true, + derivatives: true, + processingStatus: true, + processingError: true, + tipId: true, + attachedAt: true, + createdAt: true, + }, + }, }, }); }); @@ -352,7 +402,11 @@ describe('PaymentService', () => { expect(result.hasPrev).toBe(false); expect(mockPrisma.tip.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { creatorId, status: 'completed' }, + where: { + creatorId, + status: 'completed', + moderationState: 'visible', + }, skip: 0, take: 20, }) diff --git a/src/domains/payments/payment.service.ts b/src/domains/payments/payment.service.ts index e147128..a61ffe7 100644 --- a/src/domains/payments/payment.service.ts +++ b/src/domains/payments/payment.service.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, Tip } from '@prisma/client'; import { BaseService } from '../../services/base.service'; import { CreateTipRequest, @@ -8,28 +8,27 @@ import { BuildTransactionResponse, SubmitTransactionResponse, } from './payment.types'; -import { ValidationError, NotFoundError, UnauthorizedError, ConflictError } from '../../utils/errors'; import { - buildPaymentTransaction, - submitSignedTransaction, -} from '../../lib/stellar/transactions'; + ValidationError, + NotFoundError, + UnauthorizedError, + ConflictError, +} from '../../utils/errors'; +import { buildPaymentTransaction, submitSignedTransaction } from '../../lib/stellar/transactions'; import { logger } from '../../utils/logger'; -import { - sanitizePageSize, - sanitizePageNumber, - parseSortParameters, -} from '../../utils/pagination'; +import { sanitizePageSize, sanitizePageNumber, parseSortParameters } from '../../utils/pagination'; import { paginateWithCursor } from '../../db/pagination'; +import { WebhookService } from '../webhooks/webhook.service'; import { buildTipMemo, validateMemo, validatePaymentAmount } from '../../lib/stellar/validation'; import { config } from '../../config/env'; import { MAX_MEDIA_PER_TIP, type MediaView } from '../media/media.types'; import { buildCdnUrl } from '../media/media.storage'; import { TIP_VISIBLE_STATE } from '../moderation/moderation.types'; import { TipFilterInput, buildTipWhere, describeTipFilters } from './tip-filters'; -import { getRequestId, withRequestIdPayload } from '../../lib/requestContext'; import { invalidateCaches, tipCacheKeys } from '../../lib/cache/invalidation'; - /** + + * Columns required to build a TipResponse. * Media columns selected alongside a tip. Only `ready` media is exposed: rows * still scanning, rejected, or failed are invisible to tip readers. */ @@ -66,45 +65,36 @@ const TIP_RESPONSE_SELECT = { transactionHash: true, createdAt: true, updatedAt: true, -media: { + media: { where: { status: 'ready' }, orderBy: { attachedAt: 'asc' }, select: TIP_MEDIA_SELECT, }, + assetCode: true, assetIssuer: true, assetDecimals: true, // Needed so a tip removed by a moderation decision 404s on direct lookup (#62). moderationState: true, } as const; +function isUniqueConstraintError(error: unknown): boolean { + const code = (error as { code?: string })?.code; + return code === 'P2002'; +} /** * Content moderation read-side (#62): a tip that is hidden while a report is * open, or removed by a resolved decision, is not part of any public tip list. - * `ModerationService` owns the state; this is what it means for readers. */ const VISIBLE_TIPS_ONLY = { moderationState: TIP_VISIBLE_STATE } as const; -/** - * Raised internally when the version-guarded update loses a race. The retry loop - * in `updateTipStatus` catches it; callers get a 409 with a retry hint instead. - */ class OptimisticLockError extends Error { - constructor(tipId: string) { - super(`Tip ${tipId} was modified concurrently`); + constructor(public readonly tipId: string) { + super(`Optimistic lock conflict for tip ${tipId}`); this.name = 'OptimisticLockError'; } } -/** Prisma reports unique-constraint violations (e.g. transactionHash) as P2002. */ -function isUniqueConstraintError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - (error as { code?: string }).code === 'P2002' - ); -} - export class PaymentService extends BaseService { constructor(private prisma: PrismaClient) { super(); @@ -146,7 +136,10 @@ export class PaymentService extends BaseService { const requestedAsset = data.assetId ? await this.prisma.stellarAsset.findFirst({ where: { id: data.assetId, enabled: true } }) - : await this.prisma.stellarAsset.findFirst({ where: { enabled: true, code: 'USDC' }, orderBy: { priority: 'desc' } }); + : await this.prisma.stellarAsset.findFirst({ + where: { enabled: true, code: 'USDC' }, + orderBy: { priority: 'desc' }, + }); if (data.assetId && !requestedAsset) throw new ValidationError('Asset is unavailable'); // Verify sender is not tipping themselves @@ -277,14 +270,24 @@ export class PaymentService extends BaseService { } logger.info(`Tip created: ${tip.id} from ${userId} to ${data.creatorId} for ${data.amount}`); -if (attachableMedia.length) { + await new WebhookService(this.prisma).dispatchEvent(tip.creatorId, tip.id, 'tip.created', { + tipId: tip.id, + amount: tip.amount, + message: tip.message, + status: tip.status, + }); + + if (attachableMedia.length) { // Stamp `attachedAt` so the media is ordered and the prune sweep can tell // an attached upload from an abandoned one. Best-effort: the tip exists // and is authoritative even if this bookkeeping write fails. - await this.stampMediaAttached(tip.id, attachableMedia.map((media) => media.id)); + await this.stampMediaAttached( + tip.id, + attachableMedia.map((media) => media.id) + ); logger.info(`Tip ${tip.id} attached ${attachableMedia.length} media item(s)`); } - await invalidateCaches(tipCacheKeys(tip.id, data.creatorId), 'tip.created'); + return this.formatTipResponse(tip); }); } @@ -387,8 +390,8 @@ if (attachableMedia.length) { const safePage = sanitizePageNumber(page); const safePageSize = sanitizePageSize(pageSize, 20); -// Database-side filtering (#56): status, date range, amount range, - // text search, sender and creator are all pushed into the query. + // Database-side filtering: public lists exclude tips hidden or removed + // by moderation while retaining the existing tip filters. const where = buildTipWhere({ creatorId, ...VISIBLE_TIPS_ONLY }, options); const sortFields = parseSortParameters( @@ -450,9 +453,9 @@ if (attachableMedia.length) { throw new NotFoundError('Creator'); } -const where = buildTipWhere({ creatorId, ...VISIBLE_TIPS_ONLY }, params); + const where = buildTipWhere({ creatorId, ...VISIBLE_TIPS_ONLY }, params); - const result = await paginateWithCursor( + const result = await paginateWithCursor( this.prisma.tip, { limit: params.limit, @@ -504,7 +507,7 @@ const where = buildTipWhere({ creatorId, ...VISIBLE_TIPS_ONLY }, params); const safePage = sanitizePageNumber(page); const safePageSize = sanitizePageSize(pageSize, 20); -const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, options); + const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, options); const sortFields = parseSortParameters( options.sortBy, @@ -557,9 +560,9 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, option } = {} ) { return this.executeWithLogging('payment.getUserTipHistoryCursor', async () => { -const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params); + const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params); - const result = await paginateWithCursor( + const result = await paginateWithCursor( this.prisma.tip, { limit: params.limit, @@ -876,7 +879,9 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params pendingBalance: { increment: tip.amount }, }, }); - logger.info(`Tip completed and creator earnings updated: ${tipId}, amount: ${tip.amount}`); + logger.info( + `Tip completed and creator earnings updated: ${tipId}, amount: ${tip.amount}` + ); shouldDispatchWebhook = true; } else { shouldDispatchWebhook = false; // Reset in case of retry @@ -914,29 +919,24 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params throw new ConflictError('Tip update did not complete', { tipId }); } - // Dispatch webhooks outside transaction + // Invalidate caches after the successful transaction. await invalidateCaches(tipCacheKeys(tipId, finalTip.creatorId), 'tip.completed'); + + // Dispatch the webhook outside the transaction only after a successful completion. if (shouldDispatchWebhook) { try { - // Dynamic import to avoid circular dependencies if any - const { webhookDispatchQueue } = await import('../../lib/queue'); - - const webhooks = await this.prisma.webhook.findMany({ - where: { creatorId: finalTip.creatorId, active: true }, - }); - - for (const webhook of webhooks) { - if (webhook.events.includes('tip.completed') || webhook.events.includes('tip.confirmed')) { - await webhookDispatchQueue.add('webhook-dispatch', { - webhookId: webhook.id, - eventType: 'tip.completed', - requestId: getRequestId(), - payload: withRequestIdPayload({ tip: finalTip }), - }); + await new WebhookService(this.prisma).dispatchEvent( + finalTip.creatorId, + finalTip.id, + 'payment.completed', + { + tipId: finalTip.id, + amount: finalTip.amount, + transactionHash: finalTip.transactionHash, } - } - } catch (e) { - logger.error(`Failed to dispatch webhooks for tip ${tipId}:`, e); + ); + } catch (error) { + logger.error({ error, tipId }, 'Failed to dispatch payment.completed webhook'); } } @@ -1002,13 +1002,13 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params memo, }); - const transaction = transactionBuilder.build(); - const transactionEnvelope = transaction.toEnvelope().toXDR(); + const transaction = transactionBuilder; + const transactionEnvelope = transaction.toXDR(); logger.debug(`Payment transaction built for tip: ${tipId}`); return { - transactionEnvelope: transactionEnvelope as any as string, + transactionEnvelope: transactionEnvelope, tipId, fee: 100, // Base fee in stroops }; @@ -1159,7 +1159,17 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params /** * Format database tip record to response DTO */ - private formatTipResponse(tip: any): TipResponse { + private formatTipResponse(tip: { + id: string; + fromUserId: string; + creatorId: string; + amount: number; + message: string | null; + status: string; + transactionHash?: string | null; + createdAt: Date; + updatedAt: Date; + }): TipResponse { return { id: tip.id, fromUserId: tip.fromUserId, @@ -1168,7 +1178,9 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params message: tip.message, status: tip.status as TipResponse['status'], transactionHash: tip.transactionHash || null, - media: Array.isArray(tip.media) ? tip.media.map((row: any) => this.formatAttachedMedia(row)) : [], + media: Array.isArray(tip.media) + ? tip.media.map((row: any) => this.formatAttachedMedia(row)) + : [], createdAt: tip.createdAt.toISOString(), updatedAt: tip.updatedAt.toISOString(), assetCode: tip.assetCode ?? 'USDC', @@ -1186,7 +1198,10 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params const derivativeUrl = (variant: string): string | null => { const derivative = derivatives.find((entry: any) => entry?.variant === variant); if (!derivative?.key) return null; - return buildCdnUrl(config.MEDIA_CDN_BASE_URL, derivative.key) ?? `/api/v1/media/${media.id}/content?variant=${variant}`; + return ( + buildCdnUrl(config.MEDIA_CDN_BASE_URL, derivative.key) ?? + `/api/v1/media/${media.id}/content?variant=${variant}` + ); }; return { @@ -1199,14 +1214,19 @@ const where = buildTipWhere({ fromUserId: userId, ...VISIBLE_TIPS_ONLY }, params width: media.width ?? null, height: media.height ?? null, durationSeconds: media.durationSeconds ?? null, - url: buildCdnUrl(config.MEDIA_CDN_BASE_URL, media.storageKey) ?? `/api/v1/media/${media.id}/content`, + url: + buildCdnUrl(config.MEDIA_CDN_BASE_URL, media.storageKey) ?? + `/api/v1/media/${media.id}/content`, previewUrl: derivativeUrl('preview'), thumbnailUrl: derivativeUrl('thumbnail'), processing: { status: media.processingStatus ?? 'pending', error: media.processingError ?? null, }, - createdAt: (media.createdAt instanceof Date ? media.createdAt : new Date(media.createdAt)).toISOString(), + createdAt: (media.createdAt instanceof Date + ? media.createdAt + : new Date(media.createdAt) + ).toISOString(), attachedTipId: media.tipId ?? null, }; } diff --git a/src/domains/webhooks/__tests__/webhook-secret-rotation.test.ts b/src/domains/webhooks/__tests__/webhook-secret-rotation.test.ts index 1c64857..1654d08 100644 --- a/src/domains/webhooks/__tests__/webhook-secret-rotation.test.ts +++ b/src/domains/webhooks/__tests__/webhook-secret-rotation.test.ts @@ -21,6 +21,17 @@ describe('Webhook Secret Rotation', () => { const userId = 'user-789'; const currentSecret = 'current-secret-32-bytes-long!!!'; const ipAddress = '127.0.0.1'; + const makeWebhookResponse = (overrides: Record = {}) => ({ + id: webhookId, + creatorId, + url: 'https://example.com/webhook', + events: ['tip.created'], + secret: currentSecret, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }); beforeEach(() => { vi.clearAllMocks(); @@ -49,15 +60,15 @@ describe('Webhook Secret Rotation', () => { }; vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); - vi.mocked(mockPrisma.webhook.update).mockResolvedValue(rotatedWebhook as any); + vi.mocked(mockPrisma.webhook.update).mockImplementation((args) => { + return Promise.resolve({ + ...rotatedWebhook, + secret: args.data.secret, + } as any); + }); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); - const result = await service.rotateWebhookSecret( - webhookId, - creatorId, - userId, - ipAddress - ); + const result = await service.rotateWebhookSecret(webhookId, creatorId, userId, ipAddress); expect(result.id).toBe(webhookId); expect(result.secret).not.toBe(currentSecret); // New secret generated @@ -113,11 +124,12 @@ describe('Webhook Secret Rotation', () => { }; vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); - vi.mocked(mockPrisma.webhook.update).mockResolvedValue({ - ...webhook, - previousSecret: currentSecret, - secretRotatedAt: new Date(), - } as any); + vi.mocked(mockPrisma.webhook.update).mockResolvedValue( + makeWebhookResponse({ + previousSecret: currentSecret, + secretRotatedAt: new Date(), + }) as any + ); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); await service.rotateWebhookSecret(webhookId, creatorId, userId, ipAddress); @@ -138,10 +150,11 @@ describe('Webhook Secret Rotation', () => { const beforeRotation = new Date(); vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); - vi.mocked(mockPrisma.webhook.update).mockResolvedValue({ - ...webhook, - secretRotatedAt: new Date(), - } as any); + vi.mocked(mockPrisma.webhook.update).mockResolvedValue( + makeWebhookResponse({ + secretRotatedAt: new Date(), + }) as any + ); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); await service.rotateWebhookSecret(webhookId, creatorId, userId, ipAddress); @@ -161,10 +174,11 @@ describe('Webhook Secret Rotation', () => { vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); vi.mocked(mockPrisma.webhook.update).mockImplementation((args) => { - return Promise.resolve({ - ...webhook, - secret: args.data.secret, - } as any); + return Promise.resolve( + makeWebhookResponse({ + secret: args.data.secret, + }) as any + ); }); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); @@ -242,25 +256,21 @@ describe('Webhook Secret Rotation', () => { const rotationDate = new Date(); vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); - vi.mocked(mockPrisma.webhook.update).mockResolvedValue({ - ...webhook, - secret: newSecret, - previousSecret: currentSecret, - secretRotatedAt: rotationDate, - } as any); + vi.mocked(mockPrisma.webhook.update).mockResolvedValue( + makeWebhookResponse({ + secret: newSecret, + previousSecret: currentSecret, + secretRotatedAt: rotationDate, + }) as any + ); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); - const result = await service.rotateWebhookSecret( - webhookId, - creatorId, - userId, - ipAddress - ); + const result = await service.rotateWebhookSecret(webhookId, creatorId, userId, ipAddress); // After rotation, webhook should have both secrets // This would be verified by the verification module which checks both expect(result.secret).toBe(newSecret); - + const updateCall = vi.mocked(mockPrisma.webhook.update).mock.calls[0][0]; expect(updateCall.data.previousSecret).toBe(currentSecret); }); @@ -277,10 +287,12 @@ describe('Webhook Secret Rotation', () => { vi.mocked(mockPrisma.webhook.findUnique).mockResolvedValue(webhook as any); vi.mocked(mockPrisma.webhook.update).mockImplementation((args) => { secrets.push(args.data.secret as string); - return Promise.resolve({ - ...webhook, - secret: args.data.secret, - } as any); + + return Promise.resolve( + makeWebhookResponse({ + secret: args.data.secret, + }) as any + ); }); vi.mocked(mockPrisma.auditLog.create).mockResolvedValue({} as any); diff --git a/src/domains/webhooks/events.test.ts b/src/domains/webhooks/events.test.ts new file mode 100644 index 0000000..d8e894b --- /dev/null +++ b/src/domains/webhooks/events.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + createWebhookSignature, + isWebhookEventType, + verifyWebhookSignature, +} from './events'; + +describe('webhook event contracts', () => { + it('accepts only supported version 1 event types', () => { + expect(isWebhookEventType('tip.created')).toBe(true); + expect(isWebhookEventType('creator.verified')).toBe(true); + expect(isWebhookEventType('payment.completed')).toBe(true); + expect(isWebhookEventType('tip.confirmed')).toBe(false); + }); + + it('creates and verifies an HMAC SHA-256 signature over the raw body', () => { + const body = JSON.stringify({ id: 'evt_1', type: 'tip.created', version: '1' }); + const signature = createWebhookSignature(body, 'secret'); + expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/); + expect(verifyWebhookSignature(body, 'secret', signature)).toBe(true); + expect(verifyWebhookSignature(`${body} `, 'secret', signature)).toBe(false); + expect(verifyWebhookSignature(body, 'wrong-secret', signature)).toBe(false); + }); +}); diff --git a/src/domains/webhooks/events.ts b/src/domains/webhooks/events.ts new file mode 100644 index 0000000..0060dc8 --- /dev/null +++ b/src/domains/webhooks/events.ts @@ -0,0 +1,2 @@ +// Compatibility export for existing consumers. +export * from './webhook.events'; diff --git a/src/domains/webhooks/webhook-incoming.routes.ts b/src/domains/webhooks/webhook-incoming.routes.ts index a425a0c..2e50589 100644 --- a/src/domains/webhooks/webhook-incoming.routes.ts +++ b/src/domains/webhooks/webhook-incoming.routes.ts @@ -34,145 +34,151 @@ export const registerIncomingWebhookRoutes = (app: FastifyInstance, prisma: Pris /** * POST /api/v1/webhooks/incoming/:webhookId - * + * * Receive and verify incoming webhooks from creators. - * + * * Required headers: * - X-Webhook-Signature: HMAC-SHA256 signature * - X-Webhook-Timestamp: Unix timestamp (seconds) * - X-Webhook-Nonce: Unique request identifier - * + * * The signature is computed as: HMAC-SHA256(secret, "{timestamp}.{nonce}.{body}") */ scope.post<{ - Params: { webhookId: string }; - Body: Record; - }>( - '/api/v1/webhooks/incoming/:webhookId', - { - schema: { - description: 'Receive incoming webhook from a creator with signature verification', - params: { - type: 'object', - required: ['webhookId'], - properties: { - webhookId: { type: 'string', description: 'Webhook ID' }, - }, - }, - headers: { - type: 'object', - properties: { - 'x-webhook-signature': { - type: 'string', - description: 'HMAC-SHA256 signature of the request', - }, - 'x-webhook-timestamp': { - type: 'string', - description: 'Unix timestamp in seconds', + Params: { webhookId: string }; + Body: Record; + }>( + '/api/v1/webhooks/incoming/:webhookId', + { + schema: { + description: 'Receive incoming webhook from a creator with signature verification', + params: { + type: 'object', + required: ['webhookId'], + properties: { + webhookId: { type: 'string', description: 'Webhook ID' }, }, - 'x-webhook-nonce': { - type: 'string', - description: 'Unique nonce to prevent replay attacks', + }, + headers: { + type: 'object', + properties: { + 'x-webhook-signature': { + type: 'string', + description: 'HMAC-SHA256 signature of the request', + }, + 'x-webhook-timestamp': { + type: 'string', + description: 'Unix timestamp in seconds', + }, + 'x-webhook-nonce': { + type: 'string', + description: 'Unique nonce to prevent replay attacks', + }, }, }, - }, - response: { - 200: { description: 'Webhook received and verified' }, - 400: { description: 'Invalid request or signature verification failed' }, - 401: { description: 'Signature verification failed' }, - 404: { description: 'Webhook not found' }, - }, - } as any, - }, - async (request: FastifyRequest, reply: FastifyReply) => { - const { webhookId } = request.params as { webhookId: string }; + response: { + 200: { description: 'Webhook received and verified' }, + 400: { description: 'Invalid request or signature verification failed' }, + 401: { description: 'Signature verification failed' }, + 404: { description: 'Webhook not found' }, + }, + } as any, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { webhookId } = request.params as { webhookId: string }; - try { - // Get raw body for signature verification - const rawBody = (request as RawBodyRequest).rawBody || JSON.stringify(request.body); + try { + // Get raw body for signature verification + const rawBody = (request as RawBodyRequest).rawBody || JSON.stringify(request.body); - // Parse webhook headers - const headers = parseWebhookHeaders(request.headers as Record); + // Parse webhook headers + const headers = parseWebhookHeaders( + request.headers as Record + ); - // Verify webhook signature - const verificationResult = await verifyCreatorWebhookSignature( - prisma, - webhookId, - rawBody, - headers, - undefined, - request.ip - ); + // Verify webhook signature + const verificationResult = await verifyCreatorWebhookSignature( + prisma, + webhookId, + rawBody, + headers, + undefined, + request.ip + ); + + if (!verificationResult.valid) { + logger.warn( + { + webhookId, + reason: verificationResult.reason, + timestamp: verificationResult.timestamp, + headers: { + hasSignature: !!headers.signature, + hasTimestamp: !!headers.timestamp, + hasNonce: !!headers.nonce, + }, + }, + 'Incoming webhook verification failed' + ); + + reply + .code(401) + .send( + formatError( + `Webhook verification failed: ${verificationResult.reason}`, + 'WEBHOOK_VERIFICATION_FAILED' + ) + ); + return; + } - if (!verificationResult.valid) { - logger.warn( + // Log successful verification + logger.info( { webhookId, - reason: verificationResult.reason, timestamp: verificationResult.timestamp, - headers: { - hasSignature: !!headers.signature, - hasTimestamp: !!headers.timestamp, - hasNonce: !!headers.nonce, - }, + usedPreviousSecret: verificationResult.usedPreviousSecret, }, - 'Incoming webhook verification failed' + 'Incoming webhook verified successfully' ); - reply.code(401).send( - formatError( - `Webhook verification failed: ${verificationResult.reason}`, - 'WEBHOOK_VERIFICATION_FAILED' - ) - ); - return; - } + // Process the webhook payload + const body = request.body as Record; - // Log successful verification - logger.info( - { - webhookId, - timestamp: verificationResult.timestamp, - usedPreviousSecret: verificationResult.usedPreviousSecret, - }, - 'Incoming webhook verified successfully' - ); - - // Process the webhook payload - const body = request.body as Record; - - // Here you would add your webhook processing logic - // For now, we just acknowledge receipt - await processIncomingWebhook(prisma, webhookId, body, verificationResult.timestamp!); - - reply.code(200).send( - formatSuccess({ - received: true, - webhookId, - timestamp: verificationResult.timestamp, - }) - ); - } catch (error) { - logger.error( - { webhookId, error: error instanceof Error ? error.message : 'Unknown error' }, - 'Error processing incoming webhook' - ); + // Here you would add your webhook processing logic + // For now, we just acknowledge receipt + await processIncomingWebhook(prisma, webhookId, body, verificationResult.timestamp!); - if (error instanceof AppError) { - reply.code(error.statusCode).send(formatError(error.message, error.code)); - } else { - reply.code(500).send( - formatError('Internal server error processing webhook', 'WEBHOOK_PROCESSING_ERROR') + reply.code(200).send( + formatSuccess({ + received: true, + webhookId, + timestamp: verificationResult.timestamp, + }) + ); + } catch (error) { + logger.error( + { webhookId, error: error instanceof Error ? error.message : 'Unknown error' }, + 'Error processing incoming webhook' ); + + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + reply + .code(500) + .send( + formatError('Internal server error processing webhook', 'WEBHOOK_PROCESSING_ERROR') + ); + } } } - } - ); + ); }); // Close scope.register /** * GET /api/v1/webhooks/:webhookId/verification-info - * + * * Get information about webhook verification requirements. * This helps creators understand how to sign their webhooks. */ @@ -221,11 +227,7 @@ export const registerIncomingWebhookRoutes = (app: FastifyInstance, prisma: Pris url: webhook.url, active: webhook.active, algorithm: 'HMAC-SHA256', - requiredHeaders: [ - 'X-Webhook-Signature', - 'X-Webhook-Timestamp', - 'X-Webhook-Nonce', - ], + requiredHeaders: ['X-Webhook-Signature', 'X-Webhook-Timestamp', 'X-Webhook-Nonce'], signatureFormat: 'HMAC-SHA256({timestamp}.{nonce}.{body})', timestampTolerance: 300, // 5 minutes secretLastRotated: webhook.secretRotatedAt?.toISOString(), diff --git a/src/domains/webhooks/webhook.events.ts b/src/domains/webhooks/webhook.events.ts new file mode 100644 index 0000000..f6b88c3 --- /dev/null +++ b/src/domains/webhooks/webhook.events.ts @@ -0,0 +1,65 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const WEBHOOK_EVENT_TYPES = [ + 'tip.created', + 'creator.verified', + 'payment.completed', +] as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +export interface WebhookEventEnvelope< + T = Record, +> { + id: string; + type: WebhookEventType; + version: '1'; + createdAt: string; + data: T; +} + +export const isWebhookEventType = ( + event: string, +): event is WebhookEventType => + (WEBHOOK_EVENT_TYPES as readonly string[]).includes(event); + +/** + * Creates an HMAC SHA-256 signature for a webhook payload. + * + * Format: + * sha256= + */ +export const createWebhookSignature = ( + rawBody: string, + secret: string, +): string => { + const digest = createHmac('sha256', secret) + .update(rawBody, 'utf8') + .digest('hex'); + + return `sha256=${digest}`; +}; + +/** + * Verifies a webhook signature using a timing-safe comparison. + */ +export const verifyWebhookSignature = ( + rawBody: string, + secret: string, + signature: string, +): boolean => { + if (!signature?.startsWith('sha256=')) { + return false; + } + + const expectedSignature = createWebhookSignature(rawBody, secret); + + const expected = Buffer.from(expectedSignature, 'utf8'); + const supplied = Buffer.from(signature, 'utf8'); + + if (expected.length !== supplied.length) { + return false; + } + + return timingSafeEqual(expected, supplied); +}; \ No newline at end of file diff --git a/src/domains/webhooks/webhook.routes.test.ts b/src/domains/webhooks/webhook.routes.test.ts new file mode 100644 index 0000000..03d1fbc --- /dev/null +++ b/src/domains/webhooks/webhook.routes.test.ts @@ -0,0 +1,25 @@ +import type { PrismaClient } from '@prisma/client'; +import Fastify, { FastifyRequest, FastifyReply } from 'fastify'; +import { afterEach, expect, it, vi } from 'vitest'; +import { registerWebhookRoutes } from './webhook.routes'; +const { testWebhook } = vi.hoisted(() => ({ testWebhook: vi.fn() })); +vi.mock('./webhook.service', () => ({ WebhookService: vi.fn(() => ({ testWebhook })) })); +vi.mock('../../middleware/auth', () => ({ authMiddleware: async (request: FastifyRequest, reply: FastifyReply) => { + if (!request.headers.authorization) return reply.code(401).send({ error: 'Unauthorized' }); + request.user = { userId: 'user', email: 'user@example.com', role: 'creator' }; +} })); +const app = Fastify(); +const prisma = { creator: { findUnique: vi.fn().mockResolvedValue({ id: 'creator' }) } }; +registerWebhookRoutes(app, prisma as unknown as PrismaClient); +afterEach(() => { vi.clearAllMocks(); }); +it('requires authentication before testing a webhook', async () => { + const response = await app.inject({ method: 'POST', url: '/api/v1/webhooks/hook/test' }); + expect(response.statusCode).toBe(401); + expect(testWebhook).not.toHaveBeenCalled(); +}); +it('queues an authenticated test using the current creator identity', async () => { + const response = await app.inject({ method: 'POST', url: '/api/v1/webhooks/hook/test', headers: { authorization: 'Bearer token' } }); + expect(response.statusCode).toBe(202); + expect(testWebhook).toHaveBeenCalledWith('hook', 'creator'); +}); + diff --git a/src/domains/webhooks/webhook.routes.ts b/src/domains/webhooks/webhook.routes.ts index 0d8270d..adf596c 100644 --- a/src/domains/webhooks/webhook.routes.ts +++ b/src/domains/webhooks/webhook.routes.ts @@ -1,3 +1,4 @@ +import type {} from '@fastify/swagger'; import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { WebhookService, CreateWebhookRequest } from './webhook.service'; @@ -8,13 +9,34 @@ import { ValidationError, AppError } from '../../utils/errors'; export const registerWebhookRoutes = (app: FastifyInstance, prisma: PrismaClient): void => { const webhookService = new WebhookService(prisma); + app.post<{ Params: { id: string } }>( + '/api/v1/webhooks/:id/test', + { preHandler: authMiddleware }, + async (request, reply) => { + try { + const user = request.user; + if (!user) throw new Error('User not found'); + const creator = await prisma.creator.findUnique({ where: { userId: user.userId } }); + if (!creator) { + reply.code(404).send(formatError('Creator not found', 'CREATOR_NOT_FOUND')); + return; + } + await webhookService.testWebhook(request.params.id, creator.id); + reply.code(202).send(formatSuccess({ message: 'Test event queued' })); + } catch (error) { + if (error instanceof AppError) reply.code(error.statusCode).send(formatError(error.message, error.code)); + else throw error; + } + } + ); + // POST /api/v1/webhooks - Register a webhook app.post<{ Body: CreateWebhookRequest }>( '/api/v1/webhooks', { preHandler: authMiddleware, schema: { - description: 'Register a webhook to receive events when tips are created and confirmed.', + description: 'Register a webhook to receive events for tips, creator verification, and completed payments.', body: { type: 'object', required: ['url', 'events'], @@ -24,16 +46,16 @@ export const registerWebhookRoutes = (app: FastifyInstance, prisma: PrismaClient type: 'array', items: { type: 'string' }, description: - 'Events to subscribe to (tip.created, tip.confirmed, tip.failed, payout.completed)', + 'Events to subscribe to (tip.created, creator.verified, payment.completed)', }, }, }, response: { - 201: { description: 'Webhook registered' }, - 400: { description: 'Validation error' }, - 401: { description: 'Unauthorized' }, + 201: { type: 'object', additionalProperties: true, description: 'Webhook registered' }, + 400: { type: 'object', additionalProperties: true, description: 'Validation error' }, + 401: { type: 'object', additionalProperties: true, description: 'Unauthorized' }, }, - } as any, + }, }, async (request: FastifyRequest, reply: FastifyReply) => { try { @@ -84,10 +106,10 @@ export const registerWebhookRoutes = (app: FastifyInstance, prisma: PrismaClient }, }, response: { - 200: { description: 'List of webhooks' }, - 401: { description: 'Unauthorized' }, + 200: { type: 'object', additionalProperties: true, description: 'List of webhooks' }, + 401: { type: 'object', additionalProperties: true, description: 'Unauthorized' }, }, - } as any, + }, }, async (request: FastifyRequest, reply: FastifyReply) => { try { @@ -199,11 +221,11 @@ export const registerWebhookRoutes = (app: FastifyInstance, prisma: PrismaClient }, }, response: { - 200: { description: 'Webhook deleted' }, - 401: { description: 'Unauthorized' }, - 404: { description: 'Webhook not found' }, + 200: { type: 'object', additionalProperties: true, description: 'Webhook deleted' }, + 401: { type: 'object', additionalProperties: true, description: 'Unauthorized' }, + 404: { type: 'object', additionalProperties: true, description: 'Webhook not found' }, }, - } as any, + }, }, async (request: FastifyRequest, reply: FastifyReply) => { try { @@ -261,11 +283,11 @@ export const registerWebhookRoutes = (app: FastifyInstance, prisma: PrismaClient }, }, response: { - 200: { description: 'Delivery history with pagination' }, - 401: { description: 'Unauthorized' }, - 404: { description: 'Webhook not found' }, + 200: { type: 'object', additionalProperties: true, description: 'Delivery history with pagination' }, + 401: { type: 'object', additionalProperties: true, description: 'Unauthorized' }, + 404: { type: 'object', additionalProperties: true, description: 'Webhook not found' }, }, - } as any, + }, }, async (request: FastifyRequest, reply: FastifyReply) => { try { diff --git a/src/domains/webhooks/webhook.service.test.ts b/src/domains/webhooks/webhook.service.test.ts new file mode 100644 index 0000000..1a97da9 --- /dev/null +++ b/src/domains/webhooks/webhook.service.test.ts @@ -0,0 +1,257 @@ +import type { WorkerOptions, JobsOptions } from 'bullmq'; +import type { PrismaClient } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebhookService } from './webhook.service'; +import { verifyWebhookSignature } from './webhook.events'; +import { resetCircuitBreakers } from '../../lib/circuit-breaker'; + +const mocks = vi.hoisted(() => ({ + add: vi.fn(), + dead: vi.fn(), + post: vi.fn(), + prisma: { + webhook: { findUnique: vi.fn(), findMany: vi.fn() }, + webhookEvent: { create: vi.fn(), update: vi.fn() }, + }, + process: undefined as + | ((job: { + id: string; + data: unknown; + opts: JobsOptions; + attemptsMade: number; + }) => Promise) + | undefined, + options: undefined as WorkerOptions | undefined, + failedHandler: undefined as ((job: any, error: Error) => Promise) | undefined, +})); +vi.mock('../../lib/queue', () => ({ + webhookDispatchQueue: { add: mocks.add }, + bullConnection: {}, + backoffStrategy: vi.fn(), + QUEUE_NAMES: { + webhookDispatch: 'webhook-dispatch', + }, + moveToDeadLetter: mocks.dead, +})); +vi.mock('@prisma/client', () => ({ PrismaClient: vi.fn(() => mocks.prisma) })); +vi.mock('axios', () => ({ default: { post: mocks.post } })); +vi.mock('bullmq', () => ({ + Worker: vi.fn((_name, process, options) => { + mocks.process = process; + mocks.options = options; + + return { + on: vi.fn((event, handler) => { + if (event === 'failed') { + mocks.failedHandler = handler; + } + }), + }; + }), +})); +import '../../lib/workers/webhook-dispatch.worker'; + +const subscriber = { + id: 'hook', + creatorId: 'creator', + active: true, + events: ['tip.created'], + url: 'https://example.com/hook', + secret: 'secret', +}; +const service = new WebhookService(mocks.prisma as unknown as PrismaClient); +const publish = (n = 0) => service.dispatchEvent('creator', `tip-${n}`, 'tip.created', { n }); +const job = (index = 0, attemptsMade = 0) => ({ + id: mocks.add.mock.calls[index][2].jobId, + data: mocks.add.mock.calls[index][1], + opts: mocks.add.mock.calls[index][2], + attemptsMade, + updateProgress: vi.fn().mockResolvedValue(undefined), +}); + +beforeEach(() => { + resetCircuitBreakers(); + vi.clearAllMocks(); + + mocks.prisma.webhook.findMany.mockImplementation(async ({ where }) => + [subscriber].filter( + (w) => + w.creatorId === where.creatorId && + w.active === where.active && + w.events.includes(where.events.has) && + (!where.id || where.id === w.id) + ) + ); + mocks.prisma.webhook.findUnique.mockResolvedValue(subscriber); + let id = 0; + mocks.prisma.webhookEvent.create.mockImplementation(async ({ data }) => ({ + ...data, + id: `delivery-${++id}`, + })); + mocks.prisma.webhookEvent.update.mockResolvedValue({}); + mocks.add.mockResolvedValue({}); + mocks.dead.mockResolvedValue({}); + mocks.post.mockReset().mockResolvedValue({ status: 200 }); +}); + +describe('webhook delivery lifecycle', () => { + it('persists pending records before enqueueing and passes the database eventId', async () => { + mocks.add.mockImplementation(async (_name, data) => { + expect(mocks.prisma.webhookEvent.create).toHaveBeenCalled(); + expect(data.eventId).toBe('delivery-1'); + }); + await publish(); + const stored = mocks.prisma.webhookEvent.create.mock.calls[0][0].data; + expect(stored.status).toBe('pending'); + expect(JSON.parse(stored.payload)).toEqual(job().data.payload); + expect(job().data.payload).toMatchObject({ + type: 'tip.created', + version: '1', + data: { transactionId: 'tip-0' }, + }); + expect(new Date(job().data.payload.createdAt).toISOString()).toBe(job().data.payload.createdAt); + }); + it('preserves a pending record and enqueue error when Redis rejects a job', async () => { + mocks.add.mockRejectedValueOnce(new Error('Redis unavailable')); + await expect(publish()).rejects.toThrow('Redis unavailable'); + expect(mocks.prisma.webhookEvent.update).toHaveBeenCalledWith({ + where: { id: 'delivery-1' }, + data: { lastError: 'Redis unavailable' }, + }); + }); + it('delivers the signed raw envelope to the subscriber and tracks success', async () => { + await publish(); + await mocks.process(job()); + const [url, body, options] = mocks.post.mock.calls[0]; + expect(url).toBe(subscriber.url); + expect( + verifyWebhookSignature(body, subscriber.secret, options.headers['X-Dorisio-Signature']) + ).toBe(true); + expect(mocks.prisma.webhookEvent.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'delivery-1' }, + data: expect.objectContaining({ status: 'delivered', attempts: 1 }), + }) + ); + }); + it('filters subscriptions, inactive hooks, and other creators before enqueueing', async () => { + await service.dispatchEvent('creator', 'tip', 'payment.completed', {}); + await service.dispatchEvent('other', 'tip', 'tip.created', {}); + expect(mocks.add).not.toHaveBeenCalled(); + expect(mocks.prisma.webhook.findMany).toHaveBeenCalledWith({ + where: { + creatorId: 'creator', + active: true, + events: { has: 'payment.completed' }, + }, + select: { id: true, url: true }, + take: 50, + orderBy: { createdAt: 'asc' }, + }); + }); + it('uses five exponential attempts, retaining errors and dead-lettering only the last failure', async () => { + await publish(); + expect(job().opts).toMatchObject({ + attempts: 5, + backoff: { type: 'exponential', delay: 2000 }, + }); + mocks.post.mockRejectedValue(new Error('HTTP 503')); + for (let attempt = 0; attempt < 5; attempt++) { + const currentJob = job(0, attempt); + + await expect(mocks.process(currentJob)).rejects.toThrow('HTTP 503'); + + expect(mocks.prisma.webhookEvent.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + attempts: attempt + 1, + status: 'pending', + lastError: 'HTTP 503', + }), + }) + ); + + if (attempt === 4) { + const failedJob = job(0, 5); + + await mocks.failedHandler?.(failedJob, new Error('HTTP 503')); + + expect(mocks.prisma.webhookEvent.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + attempts: 5, + status: 'failed', + lastError: 'HTTP 503', + }), + }) + ); + + expect(mocks.dead).toHaveBeenCalledTimes(1); + } else { + expect(mocks.dead).not.toHaveBeenCalled(); + } + } + expect(mocks.dead).toHaveBeenCalledWith( + 'webhook-dispatch', + 'delivery-1', + expect.objectContaining({ + eventId: 'delivery-1', + }), + 'HTTP 503' + ); + }); + it('tracks success after a retry without dead-lettering', async () => { + await publish(); + mocks.post.mockRejectedValueOnce(new Error('timeout')); + await expect(mocks.process(job())).rejects.toThrow(); + await mocks.process(job(0, 1)); + expect(mocks.prisma.webhookEvent.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'delivered', attempts: 2 }), + }) + ); + expect(mocks.dead).not.toHaveBeenCalled(); + }); + it('keeps concurrent events and delivery IDs distinct', async () => { + await Promise.all(Array.from({ length: 25 }, (_, n) => publish(n))); + expect(new Set(mocks.add.mock.calls.map((c) => c[1].eventId)).size).toBe(25); + expect(new Set(mocks.add.mock.calls.map((c) => c[1].payload.id)).size).toBe(25); + await Promise.all(mocks.add.mock.calls.map((_, n) => mocks.process(job(n)))); + expect(mocks.prisma.webhookEvent.update).toHaveBeenCalledTimes(25); + }); + it('enqueues sequential publications in order and processes with concurrency one', async () => { + await publish(1); + await publish(2); + expect(mocks.add.mock.calls.map((c) => c[1].payload.data.n)).toEqual([1, 2]); + expect(mocks.options.concurrency).toBe(5); + await mocks.process(job(0)); + await mocks.process(job(1)); + expect(mocks.post.mock.calls.map((c) => JSON.parse(c[1]).data.n)).toEqual([1, 2]); + }); + it('allows a newer event to complete while an earlier event awaits retry', async () => { + await publish(1); + await publish(2); + mocks.post.mockRejectedValueOnce(new Error('timeout')); + await expect(mocks.process(job(0))).rejects.toThrow(); + await mocks.process(job(1)); + await mocks.process(job(0, 1)); + expect(mocks.post.mock.calls.map((c) => JSON.parse(c[1]).data.n)).toEqual([1, 2, 1]); + }); + it('tests only the requested owned registered webhook', async () => { + await service.testWebhook('hook', 'creator'); + expect(mocks.prisma.webhook.findMany).toHaveBeenCalledWith({ + where: { + creatorId: 'creator', + id: 'hook', + active: true, + events: { has: 'tip.created' }, + }, + select: { id: true, url: true }, + take: 50, + orderBy: { createdAt: 'asc' }, + }); + expect(job().data.payload.data.test).toBe(true); + await expect(service.testWebhook('hook', 'other')).rejects.toThrow(); + expect(mocks.add).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/domains/webhooks/webhook.service.ts b/src/domains/webhooks/webhook.service.ts index 896d550..9c1a528 100644 --- a/src/domains/webhooks/webhook.service.ts +++ b/src/domains/webhooks/webhook.service.ts @@ -5,12 +5,9 @@ import { webhookDispatchQueue } from '../../lib/queue'; import { logger } from '../../utils/logger'; import { getRequestId, withRequestIdPayload } from '../../lib/requestContext'; import crypto from 'crypto'; +import { isWebhookEventType, WebhookEventEnvelope, WebhookEventType } from './webhook.events'; import { AuditService } from '../../services/audit.service'; -import { - DEFAULT_PAGE_SIZE, - sanitizePageNumber, - sanitizePageSize, -} from '../../utils/pagination'; +import { DEFAULT_PAGE_SIZE, sanitizePageNumber, sanitizePageSize } from '../../utils/pagination'; /** Columns required to build a `WebhookResponse`. */ const WEBHOOK_RESPONSE_SELECT = { @@ -43,7 +40,7 @@ const MAX_DISPATCH_TARGETS = 50; export interface CreateWebhookRequest { url: string; - events: string[]; + events: WebhookEventType[]; } export interface WebhookResponse { @@ -78,9 +75,8 @@ export class WebhookService extends BaseService { } // Validate events - const validEvents = ['tip.created', 'tip.confirmed', 'tip.failed', 'payout.completed']; for (const event of data.events) { - if (!validEvents.includes(event)) { + if (!isWebhookEventType(event)) { throw new ValidationError(`Invalid event type: ${event}`); } } @@ -184,15 +180,31 @@ export class WebhookService extends BaseService { async dispatchEvent( creatorId: string, transactionId: string, - eventType: string, - payload: Record + eventType: WebhookEventType, + payload: Record, + webhookId?: string ): Promise { return this.executeWithLogging('webhook.dispatch', async () => { + if (!isWebhookEventType(eventType)) { + throw new ValidationError(`Invalid event type: ${eventType}`); + } + + const eventId = crypto.randomUUID(); + + const event: WebhookEventEnvelope = { + id: eventId, + type: eventType, + version: '1', + createdAt: new Date().toISOString(), + data: { ...payload, transactionId }, + }; + // Find active webhooks for this creator that subscribe to this event. - // Only the columns the queue job needs are selected. + // Filtering happens before work is placed on the delivery queue. const webhooks = await this.prisma.webhook.findMany({ where: { creatorId, + ...(webhookId ? { id: webhookId } : {}), active: true, events: { has: eventType, @@ -204,32 +216,65 @@ export class WebhookService extends BaseService { }); // Queue dispatch jobs for each webhook - await Promise.all( - webhooks.map((webhook) => - webhookDispatchQueue.add( + // Create a durable delivery record before queueing each webhook. + for (const webhook of webhooks) { + const delivery = await this.prisma.webhookEvent.create({ + data: { + webhookId: webhook.id, + eventType, + payload: JSON.stringify(event), + status: 'pending', + }, + }); + + try { + await webhookDispatchQueue.add( 'dispatch-event', { webhookId: webhook.id, - transactionId, + eventId: delivery.id, eventType, requestId: getRequestId(), - payload: withRequestIdPayload(payload), + payload: withRequestIdPayload(event), }, { attempts: 5, backoff: { type: 'exponential', delay: 2000 }, + jobId: delivery.id, } - ) - ) - ); - - logger.info( - { creatorId, eventType, queued: webhooks.length }, - 'Queued webhook dispatches' - ); + ); + } catch (error) { + // Keep the durable pending record available for recovery if enqueueing fails. + await this.prisma.webhookEvent.update({ + where: { id: delivery.id }, + data: { + lastError: error instanceof Error ? error.message : String(error), + }, + }); + + throw error; + } + } + + logger.info({ creatorId, eventType, queued: webhooks.length }, 'Queued webhook dispatches'); }); } + async testWebhook(webhookId: string, creatorId: string): Promise { + const webhook = await this.prisma.webhook.findUnique({ where: { id: webhookId } }); + if (!webhook || webhook.creatorId !== creatorId) throw new NotFoundError('Webhook'); + if (!webhook.active) throw new ValidationError('Webhook is inactive'); + const eventType = webhook.events.find(isWebhookEventType); + if (!eventType) throw new ValidationError('Webhook has no supported subscriptions'); + await this.dispatchEvent( + creatorId, + `test-${crypto.randomUUID()}`, + eventType, + { test: true }, + webhook.id + ); + } + /** * Get webhook delivery history */ @@ -273,7 +318,7 @@ export class WebhookService extends BaseService { const safePageSize = sanitizePageSize(pageSize, DEFAULT_PAGE_SIZE); const skip = (safePage - 1) * safePageSize; - const where: any = { webhookId }; + const where: { webhookId: string; status?: string } = { webhookId }; if (status) { where.status = status; } @@ -313,12 +358,6 @@ export class WebhookService extends BaseService { }); } - /** - * Rotate webhook secret - * - * Generates a new secret while keeping the previous one valid for a grace period. - * During the grace period, both secrets will be accepted for signature verification. - */ async rotateWebhookSecret( webhookId: string, creatorId: string, @@ -368,10 +407,7 @@ export class WebhookService extends BaseService { }, }); - logger.info( - { webhookId, creatorId, userId }, - 'Webhook secret rotated successfully' - ); + logger.info({ webhookId, creatorId, userId }, 'Webhook secret rotated successfully'); return this.formatWebhookResponse(updated); }); @@ -379,7 +415,7 @@ export class WebhookService extends BaseService { /** * Clear previous secret after rotation grace period - * + * * This should be called after the grace period (e.g., 7 days) to remove the old secret. * Can be run manually or as a scheduled job. */ @@ -403,16 +439,18 @@ export class WebhookService extends BaseService { }, }); - logger.info( - { count: result.count }, - 'Cleared expired previous secrets' - ); + logger.info({ count: result.count }, 'Cleared expired previous secrets'); return result.count; }); } - private formatWebhookResponse(webhook: any): WebhookResponse { + private formatWebhookResponse( + webhook: Omit & { + createdAt: Date; + updatedAt: Date; + } + ): WebhookResponse { return { id: webhook.id, creatorId: webhook.creatorId, diff --git a/src/lib/__tests__/http-client.test.ts b/src/lib/__tests__/http-client.test.ts index 520ffec..65b5d8b 100644 --- a/src/lib/__tests__/http-client.test.ts +++ b/src/lib/__tests__/http-client.test.ts @@ -11,7 +11,7 @@ describe('HttpClient', () => { beforeEach(() => { vi.clearAllMocks(); - + mockAxiosInstance = { get: vi.fn(), post: vi.fn(), @@ -162,7 +162,7 @@ describe('HttpClient', () => { it('should retry on 500 error with exponential backoff', async () => { const client = new HttpClient({ maxRetries: 2, retryDelayMs: 100 }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get .mockRejectedValueOnce(error) .mockRejectedValueOnce(error) @@ -172,7 +172,7 @@ describe('HttpClient', () => { // First retry after 100ms (1 * 100 * 2^0) await vi.advanceTimersByTimeAsync(100); - + // Second retry after 200ms (1 * 100 * 2^1) await vi.advanceTimersByTimeAsync(200); @@ -184,7 +184,7 @@ describe('HttpClient', () => { it('should retry on network errors', async () => { const client = new HttpClient({ maxRetries: 1, retryDelayMs: 100 }); const networkError = { code: 'ECONNRESET', message: 'Connection reset', config: {} }; - + mockAxiosInstance.get .mockRejectedValueOnce(networkError) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); @@ -200,7 +200,7 @@ describe('HttpClient', () => { it('should not retry on 4xx errors (except 429 and 408)', async () => { const client = new HttpClient({ maxRetries: 3 }); const error = { response: { status: 404 }, config: {}, message: 'Not found' }; - + mockAxiosInstance.get.mockRejectedValue(error); await expect(client.get('/test')).rejects.toThrow(); @@ -210,7 +210,7 @@ describe('HttpClient', () => { it('should respect skipRetry option', async () => { const client = new HttpClient({ maxRetries: 3 }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get.mockRejectedValue(error); await expect(client.get('/test', { skipRetry: true })).rejects.toThrow(); @@ -220,11 +220,11 @@ describe('HttpClient', () => { it('should throw HttpClientError after exhausting retries', async () => { const client = new HttpClient({ maxRetries: 2, retryDelayMs: 10 }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get.mockRejectedValue(error); const promise = client.get('/test'); - + await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(20); @@ -248,13 +248,13 @@ describe('HttpClient', () => { config: {}, message: 'Rate limited', }; - + mockAxiosInstance.get .mockRejectedValueOnce(rateLimitError) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); const promise = client.get('/test'); - + // Should wait 2 seconds (2000ms) await vi.advanceTimersByTimeAsync(2000); @@ -273,7 +273,7 @@ describe('HttpClient', () => { config: {}, message: 'Rate limited', }; - + mockAxiosInstance.get .mockRejectedValueOnce(rateLimitError) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); @@ -292,7 +292,7 @@ describe('HttpClient', () => { config: {}, message: 'Rate limited', }; - + mockAxiosInstance.get .mockRejectedValueOnce(rateLimitError) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); @@ -316,18 +316,18 @@ describe('HttpClient', () => { circuitBreakerOptions: { failureThreshold: 2, resetTimeoutMs: 5000 }, }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get.mockRejectedValue(error); // First failure await expect(client.get('/test')).rejects.toThrow(); - + // Second failure - should trip circuit breaker await expect(client.get('/test')).rejects.toThrow(); // Third call should fail fast with circuit breaker error await expect(client.get('/test')).rejects.toThrow(HttpClientCircuitBreakerOpenError); - + // Should only have called axios twice (third blocked by circuit breaker) expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); }); @@ -338,7 +338,7 @@ describe('HttpClient', () => { circuitBreakerOptions: { failureThreshold: 1, resetTimeoutMs: 1000 }, }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get .mockRejectedValueOnce(error) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); @@ -363,7 +363,7 @@ describe('HttpClient', () => { circuitBreakerOptions: { failureThreshold: 1 }, }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get.mockRejectedValue(error); // Trip circuit breaker @@ -373,7 +373,9 @@ describe('HttpClient', () => { await expect(client.get('/test')).rejects.toThrow(HttpClientCircuitBreakerOpenError); // Request with skipCircuitBreaker should bypass - await expect(client.get('/test', { skipCircuitBreaker: true })).rejects.toThrow(HttpClientError); + await expect(client.get('/test', { skipCircuitBreaker: true })).rejects.toThrow( + HttpClientError + ); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); }); @@ -392,7 +394,7 @@ describe('HttpClient', () => { circuitBreakerOptions: { failureThreshold: 1 }, }); const error = { response: { status: 500 }, config: {}, message: 'Server error' }; - + mockAxiosInstance.get .mockRejectedValueOnce(error) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); @@ -416,16 +418,14 @@ describe('HttpClient', () => { it('should enforce request timeout', async () => { const client = new HttpClient({ timeout: 1000 }); - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ timeout: 1000 }) - ); + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ timeout: 1000 })); }); it('should retry on timeout errors', async () => { vi.useFakeTimers(); const client = new HttpClient({ maxRetries: 1, retryDelayMs: 100 }); const timeoutError = { code: 'ECONNABORTED', message: 'Timeout', config: {} }; - + mockAxiosInstance.get .mockRejectedValueOnce(timeoutError) .mockResolvedValueOnce({ data: { success: true }, status: 200, config: {} }); diff --git a/src/lib/cache/__tests__/cache.test.ts b/src/lib/cache/__tests__/cache.test.ts index 2fed889..dea1715 100644 --- a/src/lib/cache/__tests__/cache.test.ts +++ b/src/lib/cache/__tests__/cache.test.ts @@ -94,13 +94,13 @@ describe('Cache Layer', () => { it('should handle fetch errors gracefully', async () => { const fetchFn = vi.fn().mockRejectedValue(new Error('DB Error')); - + const result = await getOrFetch({ key: 'test:123', type: CacheType.USER, fetchFn, }); - + expect(result).toBeUndefined(); }); }); @@ -162,7 +162,7 @@ describe('Cache Layer', () => { vi.mocked(withRedis).mockImplementation(async (fn: any) => { return await fn({ set: vi.fn().mockResolvedValue('OK') }); }); - + await expect(cache.set('test:jitter', { data: 'test' }, baseTtl)).resolves.not.toThrow(); }); }); @@ -188,9 +188,7 @@ describe('Cache Layer', () => { }); it('should handle concurrent reads', async () => { - const operations = Array.from({ length: 100 }, (_, i) => - cache.get(`test:concurrent:${i}`) - ); + const operations = Array.from({ length: 100 }, (_, i) => cache.get(`test:concurrent:${i}`)); await expect(Promise.all(operations)).resolves.not.toThrow(); }); diff --git a/src/lib/cache/cache-aside.ts b/src/lib/cache/cache-aside.ts index 61a6e0b..0d8283d 100644 --- a/src/lib/cache/cache-aside.ts +++ b/src/lib/cache/cache-aside.ts @@ -6,7 +6,7 @@ import { logger } from '../../utils/logger'; /** * Cache-aside pattern wrapper - * + * * This implements the cache-aside pattern where: * 1. Application looks up cache first * 2. If cache miss, query database @@ -28,7 +28,7 @@ const inFlight = new Map>(); /** * Get data with cache-aside pattern - * + * * @param options - Cache configuration and fetch function * @returns Cached or freshly fetched data */ @@ -65,7 +65,7 @@ export async function getOrFetch(options: CacheAsideOptions): Promise { } } catch (error) { logger.error(`Cache-aside error for key: ${key}`, error); - + // On cache error, fallback to direct fetch try { return await fetchFn(); @@ -78,7 +78,7 @@ export async function getOrFetch(options: CacheAsideOptions): Promise { /** * Invalidate cache entry - * + * * @param key - Cache key to invalidate */ export async function invalidate(key: string): Promise { @@ -92,7 +92,7 @@ export async function invalidate(key: string): Promise { /** * Invalidate multiple cache entries by pattern - * + * * @param pattern - Key pattern to match (e.g., "v1:user:*") * Note: This requires Redis SCAN operation, not implemented in basic cache */ @@ -104,7 +104,7 @@ export async function invalidatePattern(pattern: string): Promise { /** * Update cache entry with new data - * + * * @param key - Cache key * @param data - New data to cache * @param type - Cache type for TTL @@ -127,7 +127,7 @@ export async function update( /** * Create a cache key with proper versioning - * + * * @param type - Cache type * @param id - Resource identifier * @param version - Cache version (default: v1) @@ -139,15 +139,13 @@ export function createCacheKey(type: CacheType, id: string, version: string = 'v /** * Batch cache operations for multiple keys */ -export async function batchGetOrFetch( - operations: CacheAsideOptions[] -): Promise { - return Promise.all(operations.map(op => getOrFetch(op))); +export async function batchGetOrFetch(operations: CacheAsideOptions[]): Promise { + return Promise.all(operations.map((op) => getOrFetch(op))); } /** * Cache decorator for service methods - * + * * Usage: * ```ts * class MyService { @@ -157,26 +155,18 @@ export async function batchGetOrFetch( * } * } * ``` - * + * * Note: This is a simplified version. For full decorator support, * consider using a library like cache-manager or implementing * a more sophisticated decorator system. */ -export function Cacheable(options: { - type: CacheType; - keyPrefix: string; - ttlMs?: number; -}) { - return function ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor - ) { +export function Cacheable(options: { type: CacheType; keyPrefix: string; ttlMs?: number }) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const key = createCacheKey(options.type, `${options.keyPrefix}:${args[0]}`); - + return getOrFetch({ key, type: options.type, diff --git a/src/lib/cache/cache-warming.ts b/src/lib/cache/cache-warming.ts index 0269dd9..2f0de5b 100644 --- a/src/lib/cache/cache-warming.ts +++ b/src/lib/cache/cache-warming.ts @@ -11,7 +11,7 @@ import { cacheConfig } from '../../config/cache'; /** * Cache warming service - * + * * Preloads frequently accessed data into cache on application startup * to reduce cold start latency and improve hit rates. */ @@ -54,7 +54,7 @@ export class CacheWarmer { const warmPromises = creators.map(async (creator) => { const key = createCacheKey(CacheType.CREATOR, creator.id); const ttl = TTL_CONFIG[CacheType.CREATOR]; - + try { await cache.set(key, creator, ttl); logger.debug(`Warmed cache for creator: ${creator.username} (${creator.id})`); diff --git a/src/lib/cache/index.ts b/src/lib/cache/index.ts index fe1b226..a89b86c 100644 --- a/src/lib/cache/index.ts +++ b/src/lib/cache/index.ts @@ -88,31 +88,34 @@ export function resetStats(): void { export async function get(key: string, _opts?: CacheOptions): Promise { // try redis first try { - return await withRedis(async (client) => { - const value = await client.get(key); - if (value == null) { - stats.misses++; - cacheMisses.inc(); - updateHitRate(); - return null; - } - stats.hits++; - cacheHits.inc(); - updateHitRate(); - return JSON.parse(value); - }, async () => { - const v = memoryCache.get(key) ?? null; - if (v == null) { - stats.misses++; - cacheMisses.inc(); - } else { + return await withRedis( + async (client) => { + const value = await client.get(key); + if (value == null) { + stats.misses++; + cacheMisses.inc(); + updateHitRate(); + return null; + } stats.hits++; cacheHits.inc(); + updateHitRate(); + return JSON.parse(value); + }, + async () => { + const v = memoryCache.get(key) ?? null; + if (v == null) { + stats.misses++; + cacheMisses.inc(); + } else { + stats.hits++; + cacheHits.inc(); + } + cacheSizeGauge.set(memoryCache.size); + updateHitRate(); + return v; } - cacheSizeGauge.set(memoryCache.size); - updateHitRate(); - return v; - }); + ); } catch (err) { stats.errors++; // fallback @@ -134,40 +137,49 @@ export async function set(key: string, value: any, ttlMs?: number): Promise { - if (jitteredTtl) { - await client.set(key, s, { PX: jitteredTtl }); - } else { - await client.set(key, s); + await withRedis( + async (client) => { + if (jitteredTtl) { + await client.set(key, s, { PX: jitteredTtl }); + } else { + await client.set(key, s); + } + }, + async () => { + memoryCache.set(key, value, { ttl: ttlMs }); + cacheSizeGauge.set(memoryCache.size); } - }, async () => { - memoryCache.set(key, value, { ttl: ttlMs }); - cacheSizeGauge.set(memoryCache.size); - }); + ); } export async function del(key: string): Promise { stats.deletes++; - await withRedis(async (client) => { - await client.del(key); - }, async () => { - memoryCache.delete(key); - cacheSizeGauge.set(memoryCache.size); - }); + await withRedis( + async (client) => { + await client.del(key); + }, + async () => { + memoryCache.delete(key); + cacheSizeGauge.set(memoryCache.size); + } + ); } export async function clear(): Promise { - await withRedis(async (client) => { - // FLUSHDB is dangerous in shared environments — prefer keyspace versioning. Provided for manual clearing. - await client.flushDb(); - }, async () => { - memoryCache.clear(); - cacheSizeGauge.set(memoryCache.size); - }); + await withRedis( + async (client) => { + // FLUSHDB is dangerous in shared environments — prefer keyspace versioning. Provided for manual clearing. + await client.flushDb(); + }, + async () => { + memoryCache.clear(); + cacheSizeGauge.set(memoryCache.size); + } + ); resetStats(); } @@ -179,54 +191,71 @@ export function makeKey(version: string, namespace: string, id: string) { * Increment a counter in Redis (for analytics, view counts, etc.) */ export async function increment(key: string, amount: number = 1): Promise { - return await withRedis(async (client) => { - return await client.incrBy(key, amount); - }, async () => { - // Fallback to memory cache for counters - const current = memoryCache.get(key) || 0; - const newValue = (current as number) + amount; - memoryCache.set(key, newValue); - return newValue; - }); + return await withRedis( + async (client) => { + return await client.incrBy(key, amount); + }, + async () => { + // Fallback to memory cache for counters + const current = memoryCache.get(key) || 0; + const newValue = (current as number) + amount; + memoryCache.set(key, newValue); + return newValue; + } + ); } /** * Add to a sorted set (for leaderboards, rankings) */ export async function zAdd(key: string, score: number, member: string): Promise { - return await withRedis(async (client) => { - return await client.zAdd(key, { score, value: member }); - }, async () => { - // Fallback: not supported in memory cache, return 0 - return 0; - }); + return await withRedis( + async (client) => { + return await client.zAdd(key, { score, value: member }); + }, + async () => { + // Fallback: not supported in memory cache, return 0 + return 0; + } + ); } /** * Get range from sorted set (for leaderboards) */ -export async function zRange(key: string, start: number, end: number, reverse: boolean = false): Promise { - return await withRedis(async (client) => { - if (reverse) { - return await client.zRange(key, start, end, { REV: true }); +export async function zRange( + key: string, + start: number, + end: number, + reverse: boolean = false +): Promise { + return await withRedis( + async (client) => { + if (reverse) { + return await client.zRange(key, start, end, { REV: true }); + } + return await client.zRange(key, start, end); + }, + async () => { + // Fallback: return empty array + return []; } - return await client.zRange(key, start, end); - }, async () => { - // Fallback: return empty array - return []; - }); + ); } /** * Get score from sorted set */ export async function zScore(key: string, member: string): Promise { - return await withRedis(async (client) => { - return await client.zScore(key, member); - }, async () => { - // Fallback: return null - return null; - }); + return await withRedis( + async (client) => { + return await client.zScore(key, member); + }, + async () => { + // Fallback: return null + return null; + } + ); } /** @@ -236,7 +265,20 @@ function updateHitRate(): void { cacheHitRateGauge.set(getHitRate()); } -export default { get, set, del, clear, makeKey, increment, zAdd, zRange, zScore, getStats, getHitRate, resetStats }; +export default { + get, + set, + del, + clear, + makeKey, + increment, + zAdd, + zRange, + zScore, + getStats, + getHitRate, + resetStats, +}; // Export the makeKey function as createCacheKey for consistency export const createCacheKey = makeKey; diff --git a/src/lib/http-client.ts b/src/lib/http-client.ts index 4828f31..b0a8730 100644 --- a/src/lib/http-client.ts +++ b/src/lib/http-client.ts @@ -118,7 +118,7 @@ export class HttpClient { */ private handleRequest(config: AxiosRequestConfig): AxiosRequestConfig { const requestId = config.headers?.['X-Request-Id'] || this.generateRequestId(); - + // Add request ID for tracing config.headers = { ...config.headers, @@ -152,7 +152,7 @@ export class HttpClient { */ private handleResponse(response: AxiosResponse): AxiosResponse { const duration = Date.now() - ((response.config as any).startTime || Date.now()); - + logger.info( { method: response.config.method?.toUpperCase(), @@ -172,7 +172,7 @@ export class HttpClient { */ private handleResponseError(error: AxiosError): Promise { const duration = Date.now() - ((error.config as any)?.startTime || Date.now()); - + logger.error( { method: error.config?.method?.toUpperCase(), @@ -317,7 +317,7 @@ export class HttpClient { */ private getRetryAfterMs(error: any): number { const retryAfter = error.response?.headers?.['retry-after']; - + if (!retryAfter) { return this.retryDelayMs; } @@ -363,10 +363,7 @@ export class HttpClient { * GET request */ public async get(url: string, options?: RequestOptions): Promise> { - return this.executeWithRetry( - () => this.client.get(url, options), - options - ); + return this.executeWithRetry(() => this.client.get(url, options), options); } /** @@ -377,10 +374,7 @@ export class HttpClient { data?: any, options?: RequestOptions ): Promise> { - return this.executeWithRetry( - () => this.client.post(url, data, options), - options - ); + return this.executeWithRetry(() => this.client.post(url, data, options), options); } /** @@ -391,10 +385,7 @@ export class HttpClient { data?: any, options?: RequestOptions ): Promise> { - return this.executeWithRetry( - () => this.client.put(url, data, options), - options - ); + return this.executeWithRetry(() => this.client.put(url, data, options), options); } /** @@ -405,20 +396,14 @@ export class HttpClient { data?: any, options?: RequestOptions ): Promise> { - return this.executeWithRetry( - () => this.client.patch(url, data, options), - options - ); + return this.executeWithRetry(() => this.client.patch(url, data, options), options); } /** * DELETE request */ public async delete(url: string, options?: RequestOptions): Promise> { - return this.executeWithRetry( - () => this.client.delete(url, options), - options - ); + return this.executeWithRetry(() => this.client.delete(url, options), options); } /** diff --git a/src/lib/queue.ts b/src/lib/queue.ts index 437a481..a5cae92 100644 --- a/src/lib/queue.ts +++ b/src/lib/queue.ts @@ -91,7 +91,10 @@ function createQueue(name: string, connection: ConnectionOptions | RedisClient): } // ── First-class queues (generic jobs layer) ────────────────────────────────── -export const stellarConfirmationQueue = createQueue(QUEUE_NAMES.stellarConfirmation, bullConnection); +export const stellarConfirmationQueue = createQueue( + QUEUE_NAMES.stellarConfirmation, + bullConnection +); export const webhookDispatchQueue = createQueue(QUEUE_NAMES.webhookDispatch, bullConnection); export const emailQueue = createQueue(QUEUE_NAMES.email, bullConnection); export const imageProcessingQueue = createQueue(QUEUE_NAMES.imageProcessing, bullConnection); @@ -131,7 +134,10 @@ function attachEvents(name: string, connection: ConnectionOptions | RedisClient) return events; } -export const stellarConfirmationEvents = attachEvents(QUEUE_NAMES.stellarConfirmation, bullConnection); +export const stellarConfirmationEvents = attachEvents( + QUEUE_NAMES.stellarConfirmation, + bullConnection +); export const webhookDispatchEvents = attachEvents(QUEUE_NAMES.webhookDispatch, bullConnection); export const emailEvents = attachEvents(QUEUE_NAMES.email, bullConnection); export const imageProcessingEvents = attachEvents(QUEUE_NAMES.imageProcessing, bullConnection); diff --git a/src/lib/webhooks/creator-webhook-verification.ts b/src/lib/webhooks/creator-webhook-verification.ts index f4a9bbd..e50dede 100644 --- a/src/lib/webhooks/creator-webhook-verification.ts +++ b/src/lib/webhooks/creator-webhook-verification.ts @@ -5,12 +5,12 @@ import { AuditService } from '../../services/audit.service'; /** * Creator webhook signature verification for incoming webhooks. - * + * * Implements HMAC-SHA256 signature verification with: * - Timestamp expiration checking (5 minute window) * - Nonce-based replay attack prevention * - Secret rotation support (validates both current and previous secret during transition) - * + * * Expected headers: * - X-Webhook-Signature: HMAC-SHA256 signature of request body * - X-Webhook-Timestamp: Unix timestamp (seconds) @@ -45,7 +45,9 @@ export interface VerifyOptions { /** * Parse webhook verification headers from request */ -export function parseWebhookHeaders(headers: Record): WebhookVerificationHeaders { +export function parseWebhookHeaders( + headers: Record +): WebhookVerificationHeaders { const getHeader = (key: string): string | undefined => { const value = headers[key.toLowerCase()]; return Array.isArray(value) ? value[0] : value; @@ -61,7 +63,12 @@ export function parseWebhookHeaders(headers: Record { +async function isNonceUsed( + prisma: PrismaClient, + webhookId: string, + nonce: string +): Promise { const existingNonce = await prisma.webhookNonce.findUnique({ where: { nonce }, select: { id: true }, @@ -200,7 +211,10 @@ export async function verifyCreatorWebhookSignature( // Parse timestamp const timestamp = parseInt(headers.timestamp, 10); if (isNaN(timestamp)) { - logger.warn({ webhookId, timestamp: headers.timestamp, ipAddress }, 'Webhook verification failed: invalid timestamp'); + logger.warn( + { webhookId, timestamp: headers.timestamp, ipAddress }, + 'Webhook verification failed: invalid timestamp' + ); await auditService.record({ action: 'webhook.verification_failed', resource: 'webhook', @@ -282,7 +296,12 @@ export async function verifyCreatorWebhookSignature( } // Compute expected signature with current secret - const expectedSignature = computeWebhookSignature(payload, webhook.secret, timestamp, headers.nonce); + const expectedSignature = computeWebhookSignature( + payload, + webhook.secret, + timestamp, + headers.nonce + ); const currentSecretValid = safeEqual(headers.signature, expectedSignature); if (currentSecretValid) { @@ -337,10 +356,7 @@ export async function verifyCreatorWebhookSignature( } // Signature verification failed - logger.warn( - { webhookId, timestamp }, - 'Webhook verification failed: signature mismatch' - ); + logger.warn({ webhookId, timestamp }, 'Webhook verification failed: signature mismatch'); return { valid: false, reason: 'Signature mismatch', diff --git a/src/lib/workers/stellar-confirmation.worker.ts b/src/lib/workers/stellar-confirmation.worker.ts index 9834e00..308aa62 100644 --- a/src/lib/workers/stellar-confirmation.worker.ts +++ b/src/lib/workers/stellar-confirmation.worker.ts @@ -17,33 +17,44 @@ export function createStellarConfirmationWorker() { // payment.service passes { transactionId, transactionHash } const tipId = job.data.tipId || job.data.transactionId; const transactionHash = job.data.transactionHash; - + logger.info(`Processing Stellar confirmation for tip ${tipId} (hash: ${transactionHash})`); + await job.updateProgress(10); const status = await checkTransactionStatus(transactionHash); if (status.circuitOpen) { logger.warn(`Circuit breaker open, delaying confirmation check for tip ${tipId}`); - throw new Error('Circuit breaker open'); // Let BullMQ retry + throw new Error('Circuit breaker open'); } if (status.confirmed) { await job.updateProgress(50); - - await paymentService.updateTipStatus(tipId, { status: TipStatus.COMPLETED }); + + await paymentService.updateTipStatus(tipId, { + status: TipStatus.COMPLETED, + }); await job.updateProgress(100); - return { confirmed: true, tipId, transactionHash }; - } else { - logger.debug(`Transaction not confirmed yet for tip ${tipId}, will retry`); - throw new Error('Transaction not confirmed yet'); + + return { + confirmed: true, + tipId, + transactionHash, + }; } + + logger.debug(`Transaction not confirmed yet for tip ${tipId}, will retry`); + + throw new Error('Transaction not confirmed yet'); }, { connection: bullConnection, concurrency: config.WORKER_CONCURRENCY, - settings: { backoffStrategy }, + settings: { + backoffStrategy, + }, } ); @@ -51,16 +62,26 @@ export function createStellarConfirmationWorker() { logger.info(`Stellar confirmation worker completed job ${job.id}`); }); - worker.on('failed', async (job, err) => { - logger.error(`Stellar confirmation worker failed job ${job?.id}:`, err); + worker.on('failed', async (job, error) => { + logger.error(`Stellar confirmation worker failed job ${job?.id}:`, error); + if (job && job.attemptsMade >= (job.opts.attempts ?? 5)) { const tipId = job.data.tipId || job.data.transactionId; + try { - await paymentService.updateTipStatus(tipId, { status: TipStatus.FAILED }); - } catch (e) { - logger.error(`Failed to mark tip ${tipId} as FAILED:`, e); + await paymentService.updateTipStatus(tipId, { + status: TipStatus.FAILED, + }); + } catch (updateError) { + logger.error(`Failed to mark tip ${tipId} as FAILED:`, updateError); } - await moveToDeadLetter(QUEUE_NAMES.stellarConfirmation, String(job.id), job.data, err.message); + + await moveToDeadLetter( + QUEUE_NAMES.stellarConfirmation, + String(job.id), + job.data, + error.message + ); } }); diff --git a/src/lib/workers/webhook-dispatch.worker.ts b/src/lib/workers/webhook-dispatch.worker.ts index 80c145a..d9af7fa 100644 --- a/src/lib/workers/webhook-dispatch.worker.ts +++ b/src/lib/workers/webhook-dispatch.worker.ts @@ -1,11 +1,11 @@ import { Worker, Job } from 'bullmq'; import axios from 'axios'; import { PrismaClient } from '@prisma/client'; -import crypto from 'crypto'; import { bullConnection, backoffStrategy, moveToDeadLetter, QUEUE_NAMES } from '../queue'; import { config } from '../../config/env'; import { logger } from '../../utils/logger'; import { executeWithBreaker, CircuitBreakerOpenError } from '../circuit-breaker'; +import { createWebhookSignature } from '../../domains/webhooks/webhook.events'; import { requestIdHeaders } from '../requestContext'; const prisma = new PrismaClient(); @@ -14,89 +14,163 @@ export function createWebhookDispatchWorker() { const worker = new Worker( QUEUE_NAMES.webhookDispatch, async (job: Job) => { - const { webhookId, eventType, payload } = job.data; + const { webhookId, eventType, payload, eventId } = job.data; + logger.info(`Dispatching webhook ${webhookId} for ${eventType} event`); await job.updateProgress(20); const webhook = await prisma.webhook.findUnique({ where: { id: webhookId }, - select: { id: true, url: true, secret: true, active: true }, + select: { + id: true, + url: true, + secret: true, + active: true, + events: true, + }, }); + if (!webhook) { throw new Error(`Webhook ${webhookId} not found`); } - if (!webhook.active) { - return { delivered: false, status: 0, skipped: 'inactive' }; + + if (!webhook.active || !webhook.events.includes(eventType)) { + throw new Error('Webhook is inactive or no longer subscribed'); } - const signature = crypto - .createHmac('sha256', webhook.secret) - .update(JSON.stringify(payload)) - .digest('hex'); + const rawPayload = typeof payload === 'string' ? payload : JSON.stringify(payload); + + const signature = createWebhookSignature(rawPayload, webhook.secret); await job.updateProgress(60); - const response = await executeWithBreaker('webhook-dispatch', async () => { - return await axios.post(webhook.url, payload, { - headers: { - 'Content-Type': 'application/json', - 'X-Dorisio-Signature': `sha256=${signature}`, - 'X-Dorisio-Event': eventType, - 'X-Dorisio-Delivery-Id': job.id, - ...requestIdHeaders(String(job.data.requestId ?? job.id)), - }, - timeout: 10_000, - validateStatus: () => true, + + let response; + + try { + response = await executeWithBreaker('webhook-dispatch', async () => { + return axios.post(webhook.url, rawPayload, { + headers: { + 'Content-Type': 'application/json', + 'X-Dorisio-Signature': signature, + 'X-Dorisio-Event': eventType, + 'X-Dorisio-Delivery-Id': eventId, + 'X-Dorisio-Event-Version': '1', + ...requestIdHeaders(String(job.data.requestId ?? eventId ?? job.id)), + }, + timeout: 10_000, + maxRedirects: 0, + validateStatus: () => true, + }); }); - }).catch(err => { - if (err instanceof CircuitBreakerOpenError) { - return null; + } catch (error) { + if (error instanceof CircuitBreakerOpenError) { + response = null; + } else { + const message = error instanceof Error ? error.message : String(error); + + if (eventId) { + await prisma.webhookEvent.update({ + where: { id: eventId }, + data: { + status: 'pending', + attempts: job.attemptsMade + 1, + lastError: message, + updatedAt: new Date(), + }, + }); + } + + throw error; } - throw err; - }); + } if (response === null) { - await prisma.webhookEvent.create({ + if (eventId) { + await prisma.webhookEvent.update({ + where: { id: eventId }, + data: { + status: 'pending', + attempts: job.attemptsMade + 1, + lastError: 'Circuit breaker open', + updatedAt: new Date(), + }, + }); + } + + throw new Error('Webhook delivery skipped: circuit breaker open'); + } + + const delivered = response.status >= 200 && response.status < 300; + + if (eventId) { + await prisma.webhookEvent.update({ + where: { id: eventId }, data: { - webhookId, - eventType, - payload: JSON.stringify(payload), - status: 'failed', + status: delivered ? 'delivered' : 'pending', attempts: job.attemptsMade + 1, - lastError: 'Circuit breaker open', + lastError: delivered ? null : `HTTP ${response.status}`, + updatedAt: new Date(), }, }); - throw new Error('Webhook delivery skipped: circuit breaker open'); } - await prisma.webhookEvent.create({ - data: { - webhookId, - eventType, - payload: JSON.stringify(payload), - status: response.status >= 200 && response.status < 300 ? 'delivered' : 'failed', - attempts: job.attemptsMade + 1, - lastError: response.status >= 300 ? `HTTP ${response.status}` : null, - }, - }); - - if (response.status < 200 || response.status >= 300) { + if (!delivered) { throw new Error(`Webhook delivery failed with HTTP ${response.status}`); } await job.updateProgress(100); - return { delivered: true, status: response.status }; + + return { + delivered: true, + status: response.status, + }; }, { connection: bullConnection, concurrency: config.WORKER_CONCURRENCY, - settings: { backoffStrategy }, - }, + settings: { + backoffStrategy, + }, + } ); - worker.on('failed', async (job, err) => { - if (job && job.attemptsMade >= (job.opts.attempts ?? 5)) { - await moveToDeadLetter(QUEUE_NAMES.webhookDispatch, String(job.id), job.data, err.message); + worker.on('failed', async (job, error) => { + if (!job) { + return; } + + const maxAttempts = Number(job.opts.attempts ?? 5); + const exhausted = job.attemptsMade >= maxAttempts; + + logger.error(`Webhook dispatch worker failed job ${job.id}:`, error); + + if (!exhausted) { + return; + } + + const eventId = job.data.eventId; + + if (eventId) { + try { + await prisma.webhookEvent.update({ + where: { id: eventId }, + data: { + status: 'failed', + attempts: job.attemptsMade, + lastError: error.message, + updatedAt: new Date(), + }, + }); + } catch (updateError) { + logger.error(`Failed to mark webhook event ${eventId} as failed:`, updateError); + } + } + + await moveToDeadLetter(QUEUE_NAMES.webhookDispatch, String(job.id), job.data, error.message); + }); + + worker.on('error', (error) => { + logger.error('Webhook worker error:', error); }); return worker; @@ -104,3 +178,8 @@ export function createWebhookDispatchWorker() { /** @deprecated prefer createWebhookDispatchWorker() */ export const webhookDispatchWorker = createWebhookDispatchWorker(); + +export async function closeWebhookWorker(): Promise { + await webhookDispatchWorker.close(); + await prisma.$disconnect(); +} diff --git a/src/services/__tests__/cdn.service.test.ts b/src/services/__tests__/cdn.service.test.ts index 1dc696c..b2b6b89 100644 --- a/src/services/__tests__/cdn.service.test.ts +++ b/src/services/__tests__/cdn.service.test.ts @@ -21,14 +21,14 @@ describe('CDNService', () => { describe('Asset URL Generation', () => { it('should generate asset URL without hash when no content provided', () => { const url = service.generateAssetUrl('/images/logo.png'); - + expect(url).toMatch(/^https:\/\/cdn\.example\.com\/images\/logo\.\w+\.png$/); }); it('should generate asset URL with content hash', () => { const content = Buffer.from('test content'); const url = service.generateAssetUrl('/styles/main.css', content); - + expect(url).toContain('cdn.example.com'); expect(url).toMatch(/\/styles\/main\.\w{8}\.css$/); }); @@ -36,14 +36,14 @@ describe('CDNService', () => { it('should return plain URL when caching disabled', () => { const noCacheService = new CDNService({ ...config, enableCaching: false }); const url = noCacheService.generateAssetUrl('/test.js'); - + expect(url).toBe('https://cdn.example.com/test.js'); }); it('should return plain URL when provider is none', () => { const noCdnService = new CDNService({ ...config, provider: 'none' }); const url = noCdnService.generateAssetUrl('/test.js'); - + expect(url).toBe('https://cdn.example.com/test.js'); }); }); @@ -53,7 +53,7 @@ describe('CDNService', () => { const content = 'test content'; const hash1 = service.generateHash(content); const hash2 = service.generateHash(content); - + expect(hash1).toBe(hash2); expect(hash1).toHaveLength(8); }); @@ -61,20 +61,20 @@ describe('CDNService', () => { it('should generate different hashes for different content', () => { const hash1 = service.generateHash('content 1'); const hash2 = service.generateHash('content 2'); - + expect(hash1).not.toBe(hash2); }); it('should support custom hash length', () => { const hash = service.generateHash('test', 16); - + expect(hash).toHaveLength(16); }); it('should work with Buffer content', () => { const buffer = Buffer.from('test content'); const hash = service.generateHash(buffer); - + expect(hash).toBeTruthy(); expect(hash).toHaveLength(8); }); @@ -83,43 +83,43 @@ describe('CDNService', () => { describe('Cache TTL Rules', () => { it('should return forever cache for hashed JS files', () => { const ttl = service.getCacheTTL('/js/app.abc12345.js'); - + expect(ttl).toBe(31536000); // 1 year }); it('should return forever cache for hashed CSS files', () => { const ttl = service.getCacheTTL('/css/style.xyz98765.css'); - + expect(ttl).toBe(31536000); }); it('should return 1 month for regular JS files', () => { const ttl = service.getCacheTTL('/js/app.js'); - + expect(ttl).toBe(2592000); // 1 month }); it('should return 1 week for images', () => { const ttl = service.getCacheTTL('/images/photo.jpg'); - + expect(ttl).toBe(604800); // 1 week }); it('should return 0 for API endpoints', () => { const ttl = service.getCacheTTL('/api/users'); - + expect(ttl).toBe(0); }); it('should return default TTL for unknown paths', () => { const ttl = service.getCacheTTL('/unknown/path'); - + expect(ttl).toBe(config.cacheTTL); }); it('should handle versioned paths', () => { const ttl = service.getCacheTTL('/v1/styles/main.css'); - + expect(ttl).toBe(31536000); }); }); @@ -145,7 +145,7 @@ describe('CDNService', () => { describe('Cache-Control Headers', () => { it('should generate correct header for immutable assets', () => { const header = service.getCacheControlHeader('/js/app.abc123.js'); - + expect(header).toContain('public'); expect(header).toContain('max-age=31536000'); expect(header).toContain('immutable'); @@ -154,7 +154,7 @@ describe('CDNService', () => { it('should generate correct header for regular assets', () => { const header = service.getCacheControlHeader('/images/photo.jpg'); - + expect(header).toContain('public'); expect(header).toContain('max-age=604800'); expect(header).not.toContain('immutable'); @@ -162,7 +162,7 @@ describe('CDNService', () => { it('should generate no-cache header for API endpoints', () => { const header = service.getCacheControlHeader('/api/users'); - + expect(header).toBe('no-cache, no-store, must-revalidate'); }); }); @@ -171,21 +171,21 @@ describe('CDNService', () => { it('should skip purge when caching disabled', async () => { const noCacheService = new CDNService({ ...config, enableCaching: false }); const result = await noCacheService.purgeCache(['/test.js']); - + expect(result).toBe(true); }); it('should skip purge when provider is none', async () => { const noCdnService = new CDNService({ ...config, provider: 'none' }); const result = await noCdnService.purgeCache(['/test.js']); - + expect(result).toBe(true); }); it('should fail purge when CloudFlare credentials missing', async () => { const noCredsService = new CDNService({ ...config, zoneId: undefined }); const result = await noCredsService.purgeCache(['/test.js']); - + expect(result).toBe(false); }); @@ -195,9 +195,9 @@ describe('CDNService', () => { provider: 'cloudfront', distributionId: 'test-dist-id', }); - + const result = await cloudFrontService.purgeCache(['/test.js']); - + // Placeholder returns true expect(result).toBe(true); }); @@ -208,9 +208,9 @@ describe('CDNService', () => { service.recordRequest(true); service.recordRequest(true); service.recordRequest(false); - + const metrics = service.getMetrics(); - + expect(metrics.requests).toBe(3); expect(metrics.hits).toBe(2); expect(metrics.misses).toBe(1); @@ -220,15 +220,15 @@ describe('CDNService', () => { it('should calculate hit rate correctly', () => { for (let i = 0; i < 80; i++) service.recordRequest(true); for (let i = 0; i < 20; i++) service.recordRequest(false); - + const metrics = service.getMetrics(); - + expect(metrics.hitRate).toBe(0.8); }); it('should handle zero requests', () => { const metrics = service.getMetrics(); - + expect(metrics.requests).toBe(0); expect(metrics.hitRate).toBe(0); }); @@ -236,10 +236,10 @@ describe('CDNService', () => { it('should reset metrics', () => { service.recordRequest(true); service.recordRequest(true); - + service.resetMetrics(); const metrics = service.getMetrics(); - + expect(metrics.requests).toBe(0); expect(metrics.hits).toBe(0); expect(metrics.misses).toBe(0); @@ -258,22 +258,34 @@ describe('CDNService', () => { it('should report disabled when caching off', () => { const disabledService = new CDNService({ ...config, enableCaching: false }); - + expect(disabledService.isEnabled()).toBe(false); }); it('should report disabled when provider is none', () => { const noCdnService = new CDNService({ ...config, provider: 'none' }); - + expect(noCdnService.isEnabled()).toBe(false); }); }); describe('Cache Rules', () => { it('should have rules for all common asset types', () => { - const extensions = ['js', 'css', 'jpg', 'png', 'gif', 'webp', 'svg', 'woff', 'woff2', 'ttf', 'mp4']; - - extensions.forEach(ext => { + const extensions = [ + 'js', + 'css', + 'jpg', + 'png', + 'gif', + 'webp', + 'svg', + 'woff', + 'woff2', + 'ttf', + 'mp4', + ]; + + extensions.forEach((ext) => { const ttl = service.getCacheTTL(`/assets/file.${ext}`); expect(ttl).toBeGreaterThan(0); }); @@ -283,7 +295,7 @@ describe('CDNService', () => { // Hashed asset should use forever cache, not regular JS cache const hashedTTL = service.getCacheTTL('/js/app.abc123.js'); const regularTTL = service.getCacheTTL('/js/app.js'); - + expect(hashedTTL).toBeGreaterThan(regularTTL); }); }); diff --git a/src/services/cdn.service.ts b/src/services/cdn.service.ts index f2d4a95..ae068f4 100644 --- a/src/services/cdn.service.ts +++ b/src/services/cdn.service.ts @@ -4,7 +4,7 @@ import { Counter, Histogram } from 'prom-client'; /** * CDN Service - * + * * Provides CDN integration for static assets and media: * - CloudFlare or AWS CloudFront support * - Asset versioning and cache busting @@ -86,24 +86,32 @@ const cdnResponseTime = new Histogram({ */ export const DEFAULT_CACHE_RULES: CacheRule[] = [ // Immutable assets with hash in filename (forever cache) - { pattern: /\.(js|css|woff2?|ttf|eot|otf)\.\w{8,}\.(js|css|woff2?|ttf|eot|otf)$/, ttl: 31536000, immutable: true }, - { pattern: /\.(jpg|jpeg|png|gif|webp|svg|ico)\.\w{8,}\.(jpg|jpeg|png|gif|webp|svg|ico)$/, ttl: 31536000, immutable: true }, - + { + pattern: /\.(js|css|woff2?|ttf|eot|otf)\.\w{8,}\.(js|css|woff2?|ttf|eot|otf)$/, + ttl: 31536000, + immutable: true, + }, + { + pattern: /\.(jpg|jpeg|png|gif|webp|svg|ico)\.\w{8,}\.(jpg|jpeg|png|gif|webp|svg|ico)$/, + ttl: 31536000, + immutable: true, + }, + // Versioned assets (1 year) { pattern: /\/v\d+\//, ttl: 31536000, immutable: true }, - + // Static assets (1 month) { pattern: /\.(js|css|woff2?|ttf|eot|otf)$/, ttl: 2592000, immutable: false }, - + // Images (1 week) { pattern: /\.(jpg|jpeg|png|gif|webp|svg|ico|avif)$/, ttl: 604800, immutable: false }, - + // Videos (1 month) { pattern: /\.(mp4|webm|ogg|avi|mov)$/, ttl: 2592000, immutable: false }, - + // Fonts (1 year) { pattern: /\.(woff2?|ttf|eot|otf)$/, ttl: 31536000, immutable: false }, - + // API responses (no cache by default) { pattern: /^\/api\//, ttl: 0, immutable: false }, ]; @@ -152,11 +160,7 @@ export class CDNService { * Generate content hash for cache busting */ public generateHash(content: Buffer | string, length: number = 8): string { - return crypto - .createHash('sha256') - .update(content) - .digest('hex') - .substring(0, length); + return crypto.createHash('sha256').update(content).digest('hex').substring(0, length); } /** @@ -213,7 +217,7 @@ export class CDNService { } const parts = [`public`, `max-age=${ttl}`]; - + if (immutable) { parts.push('immutable'); } @@ -246,11 +250,14 @@ export class CDNService { const duration = Date.now() - startTime; cdnResponseTime.observe({ provider: this.config.provider, operation: 'purge' }, duration); - + if (success) { this.metrics.purges++; cdnPurgesTotal.inc({ provider: this.config.provider, status: 'success' }); - logger.info({ urls, duration, provider: this.config.provider }, 'CDN cache purged successfully'); + logger.info( + { urls, duration, provider: this.config.provider }, + 'CDN cache purged successfully' + ); } else { cdnPurgesTotal.inc({ provider: this.config.provider, status: 'failure' }); logger.error({ urls, duration, provider: this.config.provider }, 'CDN cache purge failed'); @@ -281,14 +288,14 @@ export class CDNService { { method: 'POST', headers: { - 'Authorization': `Bearer ${this.config.apiToken}`, + Authorization: `Bearer ${this.config.apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ files: urls }), } ); - const data = await response.json() as { success: boolean }; + const data = (await response.json()) as { success: boolean }; return data.success; } catch (error) { logger.error({ error }, 'CloudFlare purge request failed'); @@ -306,7 +313,7 @@ export class CDNService { } // Extract paths from URLs for CloudFront invalidation - const paths = urls.map(url => { + const paths = urls.map((url) => { try { const urlObj = new URL(url); return urlObj.pathname; @@ -315,8 +322,10 @@ export class CDNService { } }); - logger.info({ paths, distributionId: this.config.distributionId }, - 'CloudFront invalidation would be created (AWS SDK required)'); + logger.info( + { paths, distributionId: this.config.distributionId }, + 'CloudFront invalidation would be created (AWS SDK required)' + ); // Note: Actual CloudFront invalidation requires AWS SDK // This is a placeholder for the integration diff --git a/src/services/query-analysis.service.ts b/src/services/query-analysis.service.ts index b842d8e..33bf41d 100644 --- a/src/services/query-analysis.service.ts +++ b/src/services/query-analysis.service.ts @@ -1,6 +1,10 @@ import { Pool } from 'pg'; import { logger } from '../utils/logger'; -import { explainQueryWithOptions, analyzeQueryPlan, type QueryPlanResult } from '../db/query-optimizer'; +import { + explainQueryWithOptions, + analyzeQueryPlan, + type QueryPlanResult, +} from '../db/query-optimizer'; /** * Slow query threshold in milliseconds @@ -62,7 +66,7 @@ export interface MissingIndexSuggestion { /** * Query Analysis Service - * + * * Provides comprehensive query performance analysis including: * - pg_stat_statements integration * - Slow query detection (>100ms) @@ -124,8 +128,9 @@ export class QueryAnalysisService { min_exec_time: string; stddev_exec_time: string; rows: string; - }>(` - SELECT + }>( + ` + SELECT query, calls, total_exec_time, @@ -138,9 +143,11 @@ export class QueryAnalysisService { WHERE mean_exec_time > $1 ORDER BY mean_exec_time DESC LIMIT $2 - `, [SLOW_QUERY_THRESHOLD_MS, limit]); + `, + [SLOW_QUERY_THRESHOLD_MS, limit] + ); - return result.rows.map(row => ({ + return result.rows.map((row) => ({ query: row.query, calls: parseInt(row.calls, 10), totalTimeMs: parseFloat(row.total_exec_time), @@ -173,8 +180,9 @@ export class QueryAnalysisService { max_exec_time: string; min_exec_time: string; rows: string; - }>(` - SELECT + }>( + ` + SELECT query, calls, total_exec_time, @@ -185,9 +193,11 @@ export class QueryAnalysisService { FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT $1 - `, [limit]); + `, + [limit] + ); - return result.rows.map(row => ({ + return result.rows.map((row) => ({ query: row.query, calls: parseInt(row.calls, 10), totalTimeMs: parseFloat(row.total_exec_time), @@ -218,8 +228,9 @@ export class QueryAnalysisService { max_exec_time: string; min_exec_time: string; rows: string; - }>(` - SELECT + }>( + ` + SELECT query, calls, total_exec_time, @@ -230,9 +241,11 @@ export class QueryAnalysisService { FROM pg_stat_statements ORDER BY calls DESC LIMIT $1 - `, [limit]); + `, + [limit] + ); - return result.rows.map(row => ({ + return result.rows.map((row) => ({ query: row.query, calls: parseInt(row.calls, 10), totalTimeMs: parseFloat(row.total_exec_time), @@ -261,7 +274,7 @@ export class QueryAnalysisService { idx_scan: string; n_live_tup: string; }>(` - SELECT + SELECT schemaname, tablename, seq_scan, @@ -303,7 +316,10 @@ export class QueryAnalysisService { /** * Analyze a specific query and provide recommendations */ - async analyzeQuery(query: string, params: unknown[] = []): Promise<{ + async analyzeQuery( + query: string, + params: unknown[] = [] + ): Promise<{ plan: QueryPlanResult; recommendations: OptimizationRecommendation[]; }> { @@ -340,13 +356,14 @@ export class QueryAnalysisService { recommendations.push({ type: 'analyze_needed', severity: 'medium', - suggestion: 'Row estimate mismatch detected. Run ANALYZE on affected tables to update statistics.', + suggestion: + 'Row estimate mismatch detected. Run ANALYZE on affected tables to update statistics.', estimatedImpact: 'Medium - helps planner choose better execution plans', }); } if (plan.sortNodes.length > 0) { - const largeSort = plan.sortNodes.find(node => (node['Plan Rows'] as number) > 10000); + const largeSort = plan.sortNodes.find((node) => (node['Plan Rows'] as number) > 10000); if (largeSort) { recommendations.push({ type: 'missing_index', @@ -444,7 +461,7 @@ export class QueryAnalysisService { total_time: string; unique_queries: string; }>(` - SELECT + SELECT SUM(calls)::bigint AS total_calls, SUM(total_exec_time)::numeric AS total_time, COUNT(*)::int AS unique_queries diff --git a/src/utils/token-blacklist.ts b/src/utils/token-blacklist.ts index 106394d..a94f167 100644 --- a/src/utils/token-blacklist.ts +++ b/src/utils/token-blacklist.ts @@ -9,22 +9,25 @@ let cleanupInterval: ReturnType | null = null; export const initTokenBlacklist = async (prismaClient: PrismaClient): Promise => { prisma = prismaClient; - + try { // Populate cache on startup const tokens = await prisma.blacklistedToken.findMany({ - where: { expiresAt: { gt: new Date() } } + where: { expiresAt: { gt: new Date() } }, }); for (const t of tokens) { blacklistCache.set(t.token, t.expiresAt); } logger.info(`Loaded ${tokens.length} blacklisted tokens into cache`); - + // Start periodic cleanup (every hour) if (!cleanupInterval) { - cleanupInterval = setInterval(() => { - cleanupExpiredTokens().catch(err => logger.error('Periodic cleanup error', err)); - }, 60 * 60 * 1000); + cleanupInterval = setInterval( + () => { + cleanupExpiredTokens().catch((err) => logger.error('Periodic cleanup error', err)); + }, + 60 * 60 * 1000 + ); cleanupInterval.unref(); // Don't block process exit } } catch (error) { @@ -55,7 +58,7 @@ export const isTokenBlacklisted = async (token: string): Promise => { return false; } } - + // Fallback to database just in case it was missed try { if (!prisma) return false; @@ -76,7 +79,10 @@ export const isTokenBlacklisted = async (token: string): Promise => { }; /** Fail closed when the JWT was issued under an obsolete credential generation. */ -export const isUserAuthVersionCurrent = async (userId: string, authVersion = 0): Promise => { +export const isUserAuthVersionCurrent = async ( + userId: string, + authVersion = 0 +): Promise => { try { if (!prisma) return false; const user = await prisma.user.findUnique({ @@ -93,7 +99,7 @@ export const isUserAuthVersionCurrent = async (userId: string, authVersion = 0): export const cleanupExpiredTokens = async (): Promise => { try { if (!prisma) return; - + const now = new Date(); // Cleanup database await prisma.blacklistedToken.deleteMany({ @@ -107,14 +113,14 @@ export const cleanupExpiredTokens = async (): Promise => { await prisma.passwordResetToken.deleteMany({ where: { expiresAt: { lt: now } }, }); - + // Cleanup cache for (const [token, expiresAt] of blacklistCache.entries()) { if (expiresAt < now) { blacklistCache.delete(token); } } - + logger.debug('Expired tokens cleaned up'); } catch (error) { logger.error('Failed to clean up expired tokens:', error); @@ -136,7 +142,7 @@ export const blacklistRefreshToken = async (jti: string, expiresAt: Date): Promi export const isRefreshTokenBlacklisted = async (jti: string): Promise => { const token = `refresh:${jti}`; - + // Check cache first const expiresAt = blacklistCache.get(token); if (expiresAt) { @@ -147,7 +153,7 @@ export const isRefreshTokenBlacklisted = async (jti: string): Promise = return false; } } - + // Fallback to database try { if (!prisma) return false; @@ -172,4 +178,4 @@ export const closeTokenBlacklist = () => { clearInterval(cleanupInterval); cleanupInterval = null; } -}; \ No newline at end of file +};