diff --git a/app/backend/prisma/migrations/20260626150818_add_webhook_delivery/migration.sql b/app/backend/prisma/migrations/20260626150818_add_webhook_delivery/migration.sql new file mode 100644 index 00000000..83fd0af5 --- /dev/null +++ b/app/backend/prisma/migrations/20260626150818_add_webhook_delivery/migration.sql @@ -0,0 +1,79 @@ +-- CreateTable +CREATE TABLE "SorobanTransaction" ( + "id" TEXT NOT NULL PRIMARY KEY, + "claimId" TEXT, + "operation" TEXT NOT NULL, + "packageId" TEXT, + "txHash" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 5, + "lastRetryAt" DATETIME, + "nextRetryAt" DATETIME, + "lastError" TEXT, + "errorType" TEXT, + "isRetryable" BOOLEAN NOT NULL DEFAULT true, + "operatorAddress" TEXT, + "recipientAddress" TEXT, + "amount" TEXT, + "tokenAddress" TEXT, + "initiatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "submittedAt" DATETIME, + "confirmedAt" DATETIME, + "failedAt" DATETIME, + "expiredAt" DATETIME, + "correlationId" TEXT, + "metadata" JSONB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SorobanTransaction_claimId_fkey" FOREIGN KEY ("claimId") REFERENCES "Claim" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WebhookDelivery" ( + "id" TEXT NOT NULL PRIMARY KEY, + "url" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "retryCount" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "lastAttemptAt" DATETIME, + "sentAt" DATETIME, + "entityId" TEXT, + "entityType" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_claimId_idx" ON "SorobanTransaction"("claimId"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_status_idx" ON "SorobanTransaction"("status"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_operation_idx" ON "SorobanTransaction"("operation"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_txHash_idx" ON "SorobanTransaction"("txHash"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_nextRetryAt_idx" ON "SorobanTransaction"("nextRetryAt"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_correlationId_idx" ON "SorobanTransaction"("correlationId"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_attemptCount_idx" ON "SorobanTransaction"("attemptCount"); + +-- CreateIndex +CREATE INDEX "SorobanTransaction_isRetryable_nextRetryAt_idx" ON "SorobanTransaction"("isRetryable", "nextRetryAt"); + +-- CreateIndex +CREATE INDEX "WebhookDelivery_status_idx" ON "WebhookDelivery"("status"); + +-- CreateIndex +CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt"); + +-- CreateIndex +CREATE INDEX "WebhookDelivery_entityId_entityType_idx" ON "WebhookDelivery"("entityId", "entityType"); diff --git a/app/backend/src/aid/aid.module.ts b/app/backend/src/aid/aid.module.ts index 28ec2fff..ad068e76 100644 --- a/app/backend/src/aid/aid.module.ts +++ b/app/backend/src/aid/aid.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { AidService } from './aid.service'; import { AidController } from './aid.controller'; -import { RedisService } from 'cache/redis.service'; +import { RedisService } from '../../cache/redis.service'; import { HmacModule } from '../common/hmac/hmac.module'; import { WebhookHmacGuard } from '../common/guards/webhook-hmac.guard'; import { MetricsModule } from '../observability/metrics/metrics.module'; diff --git a/app/backend/src/jobs/jobs.controller.ts b/app/backend/src/jobs/jobs.controller.ts index 817eb046..4f8f53b0 100644 --- a/app/backend/src/jobs/jobs.controller.ts +++ b/app/backend/src/jobs/jobs.controller.ts @@ -13,6 +13,7 @@ export class JobsController { @InjectQueue('onchain') private onchainQueue: Queue, @InjectQueue(RETENTION_PURGE_QUEUE) private retentionPurgeQueue: Queue, @InjectQueue('dead-letter') private deadLetterQueue: Queue, + @InjectQueue('webhooks') private webhooksQueue: Queue, ) {} @ApiOperation({ @@ -59,6 +60,7 @@ export class JobsController { onchain: await this.getQueueStatus(this.onchainQueue), 'retention-purge': await this.getQueueStatus(this.retentionPurgeQueue), 'dead-letter': await this.getQueueStatus(this.deadLetterQueue), + webhooks: await this.getQueueStatus(this.webhooksQueue), }; } @@ -73,6 +75,7 @@ export class JobsController { await this.getQueueStatus(this.verificationQueue), await this.getQueueStatus(this.notificationsQueue), await this.getQueueStatus(this.onchainQueue), + await this.getQueueStatus(this.webhooksQueue), ]; const isDegraded = statuses.some(s => s.waiting > 100 || s.failed > 50); diff --git a/app/backend/src/jobs/jobs.module.ts b/app/backend/src/jobs/jobs.module.ts index aa8f036e..75c43458 100644 --- a/app/backend/src/jobs/jobs.module.ts +++ b/app/backend/src/jobs/jobs.module.ts @@ -11,6 +11,7 @@ import { DlqService } from './dlq.service'; BullModule.registerQueue({ name: 'onchain' }), BullModule.registerQueue({ name: RETENTION_PURGE_QUEUE }), BullModule.registerQueue({ name: 'dead-letter' }), + BullModule.registerQueue({ name: 'webhooks' }), ], controllers: [JobsController], providers: [DlqService], diff --git a/app/backend/src/verification/verification.controller.ts b/app/backend/src/verification/verification.controller.ts index 95c7ba61..cdd63f81 100644 --- a/app/backend/src/verification/verification.controller.ts +++ b/app/backend/src/verification/verification.controller.ts @@ -42,6 +42,7 @@ import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto'; import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto'; import { CacheResponse } from 'src/common/decorators/cache-response.decorator'; import { getCacheTTL } from 'src/common/config/cache.config'; +import { WebhookService } from './webhook.service'; @ApiTags('Verification') @ApiSecurity('x-api-key') @@ -51,6 +52,7 @@ export class VerificationController { private readonly verificationService: VerificationService, private readonly verificationFlowService: VerificationFlowService, private readonly internalNotesService: InternalNotesService, + private readonly webhookService: WebhookService, ) {} @Post('claims/:id/enqueue') @@ -137,7 +139,7 @@ export class VerificationController { @ApiUnauthorizedResponse({ description: 'Invalid or missing API key.', }) - async getMetrics() { + getMetrics() { return this.verificationService.getQueueMetrics(); } @@ -321,7 +323,7 @@ export class VerificationController { @ApiBadRequestResponse({ description: 'Invalid filter values or pagination parameters.', }) - async getReviewQueue(@Query() query: ReviewQueueQueryDto) { + getReviewQueue(@Query() query: ReviewQueueQueryDto) { return this.verificationService.getReviewQueue(query); } @@ -511,4 +513,20 @@ export class VerificationController { getNotes(@Param('id') id: string) { return this.internalNotesService.findNotesByEntity('verification', id); } + + @Post('webhooks/:id/replay') + @Version('1') + @ApiOperation({ + summary: 'Replay a failed webhook delivery', + description: 'Resets the status of a failed webhook delivery and enqueues it for retry.', + }) + @ApiOkResponse({ + description: 'Webhook delivery queued for replay successfully.', + }) + @ApiNotFoundResponse({ + description: 'The specified webhook delivery was not found.', + }) + async replayWebhook(@Param('id') id: string) { + return this.webhookService.replayWebhook(id); + } } diff --git a/app/backend/src/verification/verification.module.ts b/app/backend/src/verification/verification.module.ts index ee860e35..4d343d34 100644 --- a/app/backend/src/verification/verification.module.ts +++ b/app/backend/src/verification/verification.module.ts @@ -25,6 +25,8 @@ import { DeploymentMetadataModule } from '../deployment-metadata/deployment-meta AuditModule, NotificationsModule, EncryptionModule, + HmacModule, + MetricsModule, BullModule.registerQueueAsync({ name: 'verification', imports: [ConfigModule], @@ -36,6 +38,17 @@ import { DeploymentMetadataModule } from '../deployment-metadata/deployment-meta }), inject: [ConfigService], }), + BullModule.registerQueueAsync({ + name: 'webhooks', + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST') || 'localhost', + port: parseInt(configService.get('REDIS_PORT') || '6379'), + }, + }), + inject: [ConfigService], + }), JobsModule, DeploymentMetadataModule, // Added for contract-aware metadata ], diff --git a/app/backend/src/verification/verification.service.ts b/app/backend/src/verification/verification.service.ts index 9b293c9b..2cd3d187 100644 --- a/app/backend/src/verification/verification.service.ts +++ b/app/backend/src/verification/verification.service.ts @@ -305,815 +305,4 @@ export class VerificationService { } : null; - // Update claim with verification result including metadata - await this.prisma.claim.update({ - where: { id: claimId }, - data: { - status: shouldVerify ? 'verified' : 'requested', - anchorMetadata: - anchorMetadataToPersist === null - ? Prisma.JsonNull - : (anchorMetadataToPersist as Prisma.InputJsonValue), - }, - }); - - this.logger.log( - `Claim ${claimId} verification completed – score ${enhancedResult.score} ` + - `(threshold: ${this.verificationThreshold}, packageId: ${enhancedResult.metadata?.packageId})`, - ); - - await this.auditService.record({ - actorId: 'system', - entity: 'verification', - entityId: claimId, - action: 'complete', - metadata: { - score: enhancedResult.score, - status: shouldVerify ? 'verified' : 'requested', - packageId: enhancedResult.metadata?.packageId, - network: enhancedResult.metadata?.network, - anchorMetadata: anchorMetadataToPersist, - }, - }); - - return enhancedResult; - } - - // ------------------------------------------------------------------------- - // Core AI verification pipeline - // ------------------------------------------------------------------------- - - /** - * Orchestrates the full AI-driven verification flow: - * - * 1. If `evidenceRef` looks like a URL, attempt OCR to extract document - * fields from the image/PDF, then pass the extracted text to OpenAI. - * 2. If `evidenceRef` is a plain-text description of need, pass it directly - * to OpenAI (no OCR step needed). - * 3. OpenAI analyses the evidence against humanitarian standards and returns - * a structured JSON verdict. - * 4. If OpenAI is unavailable or returns an unparseable response, the method - * falls back to a deterministic mock result rather than throwing, so the - * queue job never fails silently. - */ - private async performAIVerification( - claim: Claim, - ): Promise { - this.logger.log(`Starting AI verification for claim ${claim.id}`); - - if (!claim.evidenceRef) { - this.logger.warn( - `Claim ${claim.id} has no evidenceRef – applying conservative mock score`, - ); - return this.buildFallbackResult( - 'No evidence provided for this claim. Manual review is required.', - ); - } - - // ------------------------------------------------------------------ - // Step 1 – Gather evidence context - // ------------------------------------------------------------------ - let evidenceContext: string; - - if (this.looksLikeUrl(claim.evidenceRef)) { - // Try OCR; on failure fall back to using the URL as-is in the prompt - evidenceContext = await this.buildOCRContext(claim); - } else { - // evidenceRef is a free-text description of need – use it directly - evidenceContext = `Applicant's stated need:\n${claim.evidenceRef}`; - } - - // ------------------------------------------------------------------ - // Step 2 – Call OpenAI for semantic analysis - // ------------------------------------------------------------------ - if (!this.openai) { - this.logger.warn( - `OpenAI client not available – falling back to mock for claim ${claim.id}`, - ); - return this.buildFallbackResult( - 'AI service not configured. Fallback scoring applied.', - ); - } - - try { - const aiResult = await this.callOpenAI(claim, evidenceContext); - return this.mapAIResponseToResult(aiResult); - } catch (err) { - // AI service unavailable or returned a bad response – degrade gracefully - this.logger.error( - `OpenAI call failed for claim ${claim.id}: ${(err as Error).message}. ` + - `Falling back to mock result.`, - ); - return this.buildFallbackResult( - 'AI analysis could not be completed. Manual review is required.', - ); - } - } - - // ------------------------------------------------------------------------- - // OpenAI interaction - // ------------------------------------------------------------------------- - - /** - * Builds a system + user prompt pair grounded in humanitarian standards - * (SPHERE Handbook, ICRC principles of humanity, impartiality, and - * proportionality) and calls the OpenAI chat completions endpoint. - * - * The model is instructed to respond with a single JSON object only – - * no markdown fences, no explanatory prose. - */ - private async callOpenAI( - claim: Claim, - evidenceContext: string, - ): Promise { - const systemPrompt = ` -You are an impartial humanitarian aid verification analyst. Your role is to assess -whether an aid claim is legitimate and the stated need is proportionate. - -You MUST apply the following humanitarian principles in every assessment: -• HUMANITY – prioritise reducing suffering; give benefit of the doubt where evidence - is limited but plausible. -• IMPARTIALITY – base the score solely on evidence and documented need, not on the - identity, nationality, religion, or ethnicity of the applicant. -• PROPORTIONALITY – the requested aid amount must be proportionate to the documented - need (e.g., household size, severity of crisis, local cost-of-living). -• NEUTRALITY – do not take sides; flag political affiliation only if it constitutes - verifiable fraud. - -Scoring rubric (score field, 0–1): - 0.85–1.00 Strong evidence, low risk, aid is proportionate. - 0.70–0.84 Reasonable evidence, minor gaps, likely legitimate. - 0.50–0.69 Insufficient or inconsistent evidence, manual review required. - 0.00–0.49 Significant red flags or clear inconsistencies detected. - -confidence field (0–1): reflect how certain you are given the available evidence. -Lower confidence when evidence is sparse, not when the claim seems unlikely. - -You MUST respond with valid JSON only – no markdown, no prose, no backticks. -The JSON object must have exactly these keys: -{ - "score": , - "confidence": , - "riskLevel": <"low"|"medium"|"high">, - "factors": [, ...], - "riskFactors": [, ...], - "recommendations": [, ...] -} -`.trim(); - - const userPrompt = ` -Claim metadata: -- Claim ID : ${claim.id} -- Campaign ID : ${claim.campaignId} -- Requested amt : ${String(claim.amount)} -- Recipient ref : ${claim.recipientRef} - -Evidence: -${evidenceContext} - -Assess this claim according to the humanitarian standards above and return only -the JSON verdict. -`.trim(); - - this.logger.log( - `Calling OpenAI (${this.openaiModel}) for claim ${claim.id}`, - ); - - const response = await this.llmCircuitBreaker.fire(() => - this.openai!.chat.completions.create( - { - model: this.openaiModel, - temperature: 0, // deterministic scoring - max_tokens: 512, - response_format: { type: 'json_object' }, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - }, - { timeout: this.aiServiceTimeout }, - ), - ); - - const rawContent = response.choices[0]?.message?.content ?? ''; - - let parsed: AIVerificationResponse; - try { - parsed = JSON.parse(rawContent) as AIVerificationResponse; - } catch { - throw new Error( - `OpenAI returned non-JSON content: ${rawContent.slice(0, 200)}`, - ); - } - - this.validateAIResponse(parsed, claim.id); - - this.logger.log( - `OpenAI verdict for claim ${claim.id}: ` + - `score=${parsed.score}, confidence=${parsed.confidence}, ` + - `riskLevel=${parsed.riskLevel}`, - ); - - return parsed; - } - - /** - * Validates that the AI response contains all required fields and that - * numeric values are within the expected 0–1 range. Throws on structural - * issues so the caller can trigger the fallback path. - */ - private validateAIResponse( - resp: AIVerificationResponse, - claimId: string, - ): void { - const requiredKeys: (keyof AIVerificationResponse)[] = [ - 'score', - 'confidence', - 'riskLevel', - 'factors', - 'riskFactors', - 'recommendations', - ]; - for (const key of requiredKeys) { - if (resp[key] === undefined || resp[key] === null) { - throw new Error( - `AI response for claim ${claimId} is missing field "${key}"`, - ); - } - } - - if (resp.score < 0 || resp.score > 1) { - throw new Error( - `AI score out of range for claim ${claimId}: ${resp.score}`, - ); - } - if (resp.confidence < 0 || resp.confidence > 1) { - throw new Error( - `AI confidence out of range for claim ${claimId}: ${resp.confidence}`, - ); - } - if (!['low', 'medium', 'high'].includes(resp.riskLevel)) { - throw new Error( - `AI riskLevel invalid for claim ${claimId}: ${resp.riskLevel}`, - ); - } - } - - // ------------------------------------------------------------------------- - // OCR helpers - // ------------------------------------------------------------------------- - - /** - * Calls the Python OCR micro-service and converts the response into a - * human-readable evidence context string suitable for the AI prompt. - * Falls back to a minimal context string if OCR fails, rather than - * throwing – the AI layer will then assign lower confidence accordingly. - */ - private async buildOCRContext(claim: Claim): Promise { - try { - const ocrResponse = await this.callOCRService(claim.evidenceRef!); - - if (!ocrResponse.success || !ocrResponse.data) { - this.logger.warn( - `OCR failed for claim ${claim.id}: ` + - `${JSON.stringify(ocrResponse.error)} – continuing with URL only`, - ); - return `Document URL (OCR failed): ${claim.evidenceRef}`; - } - - const { fields, raw_text } = ocrResponse.data; - - const fieldLines = Object.entries(fields) - .filter(([, v]) => v.value) - .map( - ([k, v]) => - ` ${k.replace(/_/g, ' ')}: "${v.value}" (confidence: ${(v.confidence * 100).toFixed(1)}%)`, - ) - .join('\n'); - - return [ - 'Identity document – extracted fields:', - fieldLines || ' (no structured fields extracted)', - '', - 'Full OCR text:', - raw_text || ' (no raw text extracted)', - ].join('\n'); - } catch (err) { - this.logger.warn( - `OCR service unavailable for claim ${claim.id}: ` + - `${(err as Error).message} – continuing without OCR data`, - ); - return `Document URL (OCR service unreachable): ${claim.evidenceRef}`; - } - } - - private async callOCRService(documentUrl: string): Promise { - try { - // Get correlation ID and propagate to OCR service - const correlationId = this.correlationUtil.getCurrentCorrelationId(); - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (correlationId) { - headers['x-correlation-id'] = correlationId; - } - - const response = await this.ocrCircuitBreaker.fire(() => - firstValueFrom( - this.httpService.post( - `${this.aiServiceUrl}/ai/ocr`, - { document_url: documentUrl }, - { - timeout: this.aiServiceTimeout, - headers, - }, - ), - ), - ); - return response.data as OCRResponse; - } catch (error) { - const err = error as { - response?: { status: number; data: unknown }; - code?: string; - message: string; - }; - if (err.response) { - throw new Error( - `OCR service returned ${err.response.status}: ` + - `${JSON.stringify(err.response.data)}`, - ); - } else if (err.code === 'ECONNREFUSED') { - throw new Error( - `OCR service unavailable at ${this.aiServiceUrl}. ` + - `Is the Python ai-service running?`, - ); - } else { - throw new Error(`OCR service call failed: ${err.message}`); - } - } - } - - // private getCorrelationIdForOutbound(): string | null { - // return this.correlationUtil.getCurrentCorrelationId(); - // } - - // ------------------------------------------------------------------------- - // Result builders - // ------------------------------------------------------------------------- - - private mapAIResponseToResult( - ai: AIVerificationResponse, - ): VerificationResult { - return { - score: parseFloat(ai.score.toFixed(3)), - confidence: parseFloat(ai.confidence.toFixed(3)), - details: { - factors: [...ai.factors, ...ai.riskFactors], - riskLevel: ai.riskLevel, - recommendations: - ai.recommendations.length > 0 ? ai.recommendations : undefined, - }, - processedAt: new Date(), - }; - } - - /** - * Conservative fallback result used when the AI service is unavailable or - * returns an invalid response. Score is set just below the default 0.7 - * threshold so the claim lands in manual review rather than being - * auto-approved or auto-rejected. - */ - private buildFallbackResult(reason: string): VerificationResult { - return { - score: 0.55, - confidence: 0.4, - details: { - factors: [reason], - riskLevel: 'medium', - recommendations: [ - 'AI verification unavailable – manual review required', - 'Verify applicant identity and evidence independently', - ], - }, - processedAt: new Date(), - }; - } - - /** - * Enhances a verification result with contract-aware metadata - */ - private async enhanceResultWithMetadata( - result: VerificationResult, - claimId: string, - campaignId: string, - ): Promise { - // Convert to DTO first - const resultDto: VerificationResultDto = { - score: result.score, - confidence: result.confidence, - details: result.details, - processedAt: result.processedAt || new Date(), - }; - - // Enhance with contract-aware metadata - const enhanced = await this.verificationMetadataService.enhanceWithMetadata( - resultDto, - claimId, - campaignId, - ); - - // Log warnings if any - if (enhanced.warnings && enhanced.warnings.length > 0) { - this.logger.warn( - `Metadata warnings for claim ${claimId}: ${enhanced.warnings.join(', ')}`, - ); - } - - return { - score: enhanced.score, - confidence: enhanced.confidence, - details: enhanced.details, - processedAt: enhanced.processedAt, - // Add metadata to result - metadata: enhanced.metadata, - warnings: enhanced.warnings, - validationErrors: enhanced.validationErrors, - } as VerificationResult; - } - - /** Heuristic: treat strings that start with http/https as URLs. */ - private looksLikeUrl(value: string): boolean { - return /^https?:\/\//i.test(value.trim()); - } - - // ------------------------------------------------------------------------- - // Mock (development / test) - // ------------------------------------------------------------------------- - - private generateMockVerification(_claim: unknown): VerificationResult { - const baseScore = 0.6 + Math.random() * 0.35; - const score = Math.min(0.95, Math.max(0.5, baseScore)); - - const factors = [ - 'Document authenticity verified', - 'Identity cross-reference passed', - 'Historical data consistent', - 'No fraud indicators detected', - ]; - - const riskLevel: 'low' | 'medium' | 'high' = - score >= 0.8 ? 'low' : score >= 0.65 ? 'medium' : 'high'; - - return { - score: parseFloat(score.toFixed(3)), - confidence: parseFloat((0.85 + Math.random() * 0.1).toFixed(3)), - details: { - factors: factors.slice(0, Math.floor(Math.random() * 2) + 2), - riskLevel, - recommendations: - riskLevel !== 'low' - ? [ - 'Manual review recommended', - 'Additional documentation may be required', - ] - : undefined, - }, - processedAt: new Date(), - }; - } - - // ------------------------------------------------------------------------- - // Test (deterministic fixture-driven) - // ------------------------------------------------------------------------- - - /** - * Deterministic verification for staging/testnet. - * - * Uses a SHA-256 hash of the claim ID to select from a set of fixture - * responses, so identical inputs always produce the same output. - */ - private _fixtures: VerificationResult[] = [ - { - score: 0.88, - confidence: 0.92, - details: { - factors: [ - 'All verification criteria met', - 'Document authenticity confirmed', - ], - riskLevel: 'low', - }, - processedAt: new Date(), - }, - { - score: 0.76, - confidence: 0.84, - details: { - factors: [ - 'Identity cross-reference passed', - 'No fraud indicators detected', - ], - riskLevel: 'low', - }, - processedAt: new Date(), - }, - { - score: 0.62, - confidence: 0.71, - details: { - factors: [ - 'Partial evidence provided', - 'Additional documentation may strengthen claim', - ], - riskLevel: 'medium', - recommendations: [ - 'Manual review recommended', - 'Request supplementary evidence', - ], - }, - processedAt: new Date(), - }, - { - score: 0.45, - confidence: 0.65, - details: { - factors: [ - 'Inconsistent information detected', - 'Claim requires further investigation', - ], - riskLevel: 'high', - recommendations: [ - 'Manual review required', - 'Verify applicant identity independently', - ], - }, - processedAt: new Date(), - }, - { - score: 0.93, - confidence: 0.95, - details: { - factors: [ - 'Strong corroborating evidence from multiple sources', - 'Aid amount proportionate to documented need', - ], - riskLevel: 'low', - }, - processedAt: new Date(), - }, - { - score: 0.55, - confidence: 0.68, - details: { - factors: [ - 'Insufficient evidence to reach full confidence', - 'Standard review triggered', - ], - riskLevel: 'medium', - recommendations: ['Manual review recommended'], - }, - processedAt: new Date(), - }, - ]; - - private generateTestVerification(claim: Claim): VerificationResult { - const hash = crypto.createHash('sha256').update(claim.id).digest('hex'); - const idx = parseInt(hash.slice(0, 8), 16) % this._fixtures.length; - const fixture = this._fixtures[idx]; - return { - ...fixture, - processedAt: new Date(), - details: { ...fixture.details }, - }; - } - - // ------------------------------------------------------------------------- - // CRUD / queue utilities (unchanged) - // ------------------------------------------------------------------------- - - create(_createVerificationDto: CreateVerificationDto) { - return 'This action adds a new verification'; - } - - async findAll() { - return Promise.resolve([]); - } - - async findOne(id: string) { - const claim = await this.prisma.claim.findUnique({ where: { id } }); - if (!claim) { - throw new NotFoundException(`Claim with ID ${id} not found`); - } - return claim; - } - - async findByUser(_userId: string) { - return Promise.resolve([]); - } - - async getReviewQueue( - query: ReviewQueueQueryDto, - ): Promise { - const limit = query.limit ?? 20; - const filters = this.buildReviewQueueFilters(query); - const paginationMode = query.getPaginationMode(); - - if (paginationMode === ReviewQueuePaginationMode.CURSOR) { - const cursorFilter = query.cursor - ? [this.buildCursorFilter(this.decodeReviewQueueCursor(query.cursor))] - : []; - const where = [...filters, ...cursorFilter]; - const items = await this.prisma.claim.findMany({ - where: where.length > 0 ? { AND: where } : undefined, - ...reviewQueueClaimArgs, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: limit + 1, - }); - - const hasNextPage = items.length > limit; - const pageItems: ReviewQueueItem[] = items.slice(0, limit); - const nextCursor = - hasNextPage && pageItems.length > 0 - ? this.encodeReviewQueueCursor(pageItems[pageItems.length - 1]) - : null; - - return { - items: pageItems, - pagination: { - mode: 'cursor', - limit, - nextCursor, - hasNextPage, - }, - filters: this.buildAppliedFilters(query), - }; - } - - const page = query.page ?? 1; - const skip = (page - 1) * limit; - const where = filters.length > 0 ? { AND: filters } : undefined; - - const [items, totalItems] = await Promise.all([ - this.prisma.claim.findMany({ - where, - ...reviewQueueClaimArgs, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - skip, - take: limit, - }), - this.prisma.claim.count({ where }), - ]); - - return { - items, - pagination: { - mode: 'page', - page, - limit, - totalItems, - totalPages: totalItems === 0 ? 0 : Math.ceil(totalItems / limit), - hasNextPage: skip + items.length < totalItems, - }, - filters: this.buildAppliedFilters(query), - }; - } - - async update(id: string, updateVerificationDto: Record) { - await this.auditService.record({ - actorId: 'system', - entity: 'verification', - entityId: id, - action: 'update', - metadata: updateVerificationDto, - }); - return { id, message: 'Verification updated' }; - } - - async remove(id: string) { - return Promise.resolve({ id, message: 'Removed' }); - } - - async getQueueMetrics() { - const [waiting, active, completed, failed] = await Promise.all([ - this.verificationQueue.getWaitingCount(), - this.verificationQueue.getActiveCount(), - this.verificationQueue.getCompletedCount(), - this.verificationQueue.getFailedCount(), - ]); - - return { - waiting, - active, - completed, - failed, - total: waiting + active + completed + failed, - }; - } - - private buildReviewQueueFilters( - query: ReviewQueueQueryDto, - ): Prisma.ClaimWhereInput[] { - const filters: Prisma.ClaimWhereInput[] = []; - - if (query.status?.length) { - filters.push({ - status: { - in: query.status, - }, - }); - } - - if (query.campaignId) { - filters.push({ campaignId: query.campaignId }); - } - - if (query.fromDate || query.toDate) { - filters.push({ - createdAt: { - ...(query.fromDate ? { gte: new Date(query.fromDate) } : {}), - ...(query.toDate ? { lte: new Date(query.toDate) } : {}), - }, - }); - } - - return filters; - } - - private buildAppliedFilters(query: ReviewQueueQueryDto) { - return { - ...(query.status?.length ? { status: query.status } : {}), - ...(query.campaignId ? { campaignId: query.campaignId } : {}), - ...(query.fromDate ? { fromDate: query.fromDate } : {}), - ...(query.toDate ? { toDate: query.toDate } : {}), - }; - } - - private buildCursorFilter( - cursor: ReviewQueueCursorPayload, - ): Prisma.ClaimWhereInput { - const cursorCreatedAt = new Date(cursor.createdAt); - - return { - OR: [ - { - createdAt: { - lt: cursorCreatedAt, - }, - }, - { - createdAt: cursorCreatedAt, - id: { - lt: cursor.id, - }, - }, - ], - }; - } - - private encodeReviewQueueCursor( - item: Pick, - ) { - return Buffer.from( - JSON.stringify({ - createdAt: item.createdAt.toISOString(), - id: item.id, - }), - 'utf8', - ).toString('base64url'); - } - - private decodeReviewQueueCursor(cursor: string): ReviewQueueCursorPayload { - try { - const parsed = JSON.parse( - Buffer.from(cursor, 'base64url').toString('utf8'), - ) as { - createdAt?: unknown; - id?: unknown; - }; - - if ( - typeof parsed.createdAt !== 'string' || - typeof parsed.id !== 'string' - ) { - throw new BadRequestException('Invalid review queue cursor'); - } - - const createdAt = new Date(parsed.createdAt); - if (Number.isNaN(createdAt.getTime())) { - throw new BadRequestException('Invalid review queue cursor'); - } - - return { - createdAt: createdAt.toISOString(), - id: parsed.id, - }; - } catch (error) { - if (error instanceof BadRequestException) { - throw error; - } - - throw new BadRequestException('Invalid review queue cursor'); - } - } -} + \ No newline at end of file diff --git a/app/backend/src/verification/webhook.processor.spec.ts b/app/backend/src/verification/webhook.processor.spec.ts new file mode 100644 index 00000000..1cc0eef0 --- /dev/null +++ b/app/backend/src/verification/webhook.processor.spec.ts @@ -0,0 +1,188 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { Job } from 'bullmq'; +import { of, throwError } from 'rxjs'; +import { WebhookProcessor } from './webhook.processor'; +import { PrismaService } from '../prisma/prisma.service'; +import { HmacService } from '../common/hmac/hmac.service'; +import { DlqService } from '../jobs/dlq.service'; +import { MetricsService } from '../observability/metrics/metrics.service'; + +describe('WebhookProcessor', () => { + let processor: WebhookProcessor; + let prismaService: PrismaService; + let httpService: HttpService; + let hmacService: HmacService; + let dlqService: DlqService; + let metricsService: MetricsService; + + const mockDelivery = { + id: 'delivery-123', + url: 'http://localhost:3002/webhook', + payload: JSON.stringify({ claimId: 'claim-123', status: 'verified' }), + status: 'pending', + entityId: 'claim-123', + entityType: 'claim', + retryCount: 0, + lastError: null, + }; + + const mockJob = { + id: 'job-123', + data: { webhookDeliveryId: 'delivery-123' }, + attemptsMade: 0, + opts: { attempts: 5 }, + } as Job<{ webhookDeliveryId: string }, any, string>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookProcessor, + { + provide: PrismaService, + useValue: { + webhookDelivery: { + findUnique: jest.fn().mockResolvedValue(mockDelivery), + update: jest.fn().mockResolvedValue(mockDelivery), + }, + }, + }, + { + provide: HttpService, + useValue: { + post: jest.fn().mockReturnValue(of({ status: 200, data: {} })), + }, + }, + { + provide: HmacService, + useValue: { + sign: jest.fn().mockReturnValue('mock-signature-hash'), + }, + }, + { + provide: DlqService, + useValue: { + moveToDlq: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: MetricsService, + useValue: { + recordWebhookDeliveryDuration: jest.fn(), + incrementWebhookRetry: jest.fn(), + incrementCallbackFailure: jest.fn(), + }, + }, + ], + }).compile(); + + processor = module.get(WebhookProcessor); + prismaService = module.get(PrismaService); + httpService = module.get(HttpService); + hmacService = module.get(HmacService); + dlqService = module.get(DlqService); + metricsService = module.get(MetricsService); + }); + + it('should be defined', () => { + expect(processor).toBeDefined(); + }); + + describe('process', () => { + it('should process webhook job and deliver successfully', async () => { + const result = await processor.process(mockJob); + + expect(prismaService.webhookDelivery.findUnique).toHaveBeenCalledWith({ + where: { id: 'delivery-123' }, + }); + + expect(prismaService.webhookDelivery.update).toHaveBeenCalledWith({ + where: { id: 'delivery-123' }, + data: { lastAttemptAt: expect.any(Date) }, + }); + + expect(hmacService.sign).toHaveBeenCalled(); + expect(httpService.post).toHaveBeenCalledWith( + mockDelivery.url, + expect.objectContaining({ + claimId: 'claim-123', + deliveryId: mockDelivery.id, + timestamp: expect.any(String), + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-webhook-signature': 'mock-signature-hash', + }), + }), + ); + + expect(metricsService.recordWebhookDeliveryDuration).toHaveBeenCalledWith( + 'verification_result', + expect.any(Number), + ); + + expect(prismaService.webhookDelivery.update).toHaveBeenCalledWith({ + where: { id: 'delivery-123' }, + data: { + status: 'sent', + sentAt: expect.any(Date), + retryCount: 0, + }, + }); + + expect(result).toEqual({ success: true, status: 200 }); + }); + + it('should skip processing if already sent', async () => { + jest.spyOn(prismaService.webhookDelivery, 'findUnique').mockResolvedValue({ + ...mockDelivery, + status: 'sent', + } as any); + + await processor.process(mockJob); + + expect(httpService.post).not.toHaveBeenCalled(); + }); + + it('should handle request failure, record state, increment metrics, and throw error', async () => { + jest.spyOn(httpService, 'post').mockReturnValue( + throwError(() => new Error('Connection refused')), + ); + + await expect(processor.process(mockJob)).rejects.toThrow('Connection refused'); + + expect(prismaService.webhookDelivery.update).toHaveBeenCalledWith({ + where: { id: 'delivery-123' }, + data: { + retryCount: 1, + lastError: 'Connection refused', + }, + }); + + expect(metricsService.incrementWebhookRetry).toHaveBeenCalledWith( + 'verification_result', + 'Connection refused', + ); + }); + }); + + describe('onFailed', () => { + it('should mark status as failed, record failure metrics, and send job to DLQ', async () => { + const error = new Error('Max retries exhausted'); + + await processor.onFailed(mockJob, error); + + expect(prismaService.webhookDelivery.update).toHaveBeenCalledWith({ + where: { id: 'delivery-123' }, + data: { status: 'failed' }, + }); + + expect(metricsService.incrementCallbackFailure).toHaveBeenCalledWith( + 'webhook_delivery', + 'Max retries exhausted', + ); + + expect(dlqService.moveToDlq).toHaveBeenCalledWith('webhooks', mockJob, error); + }); + }); +}); diff --git a/app/backend/src/verification/webhook.processor.ts b/app/backend/src/verification/webhook.processor.ts new file mode 100644 index 00000000..2f9207eb --- /dev/null +++ b/app/backend/src/verification/webhook.processor.ts @@ -0,0 +1,142 @@ +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { PrismaService } from '../prisma/prisma.service'; +import { HmacService } from '../common/hmac/hmac.service'; +import { DlqService } from '../jobs/dlq.service'; +import { MetricsService } from '../observability/metrics/metrics.service'; + +@Processor('webhooks', { + concurrency: parseInt(process.env.QUEUE_CONCURRENCY || '5', 10), +}) +export class WebhookProcessor extends WorkerHost { + private readonly logger = new Logger(WebhookProcessor.name); + + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly hmacService: HmacService, + private readonly dlqService: DlqService, + private readonly metricsService: MetricsService, + ) { + super(); + } + + async process(job: Job<{ webhookDeliveryId: string }, any, string>): Promise { + const { webhookDeliveryId } = job.data; + + this.logger.log( + `Processing webhook delivery job ${job.id} for record ${webhookDeliveryId} (attempt ${job.attemptsMade + 1})`, + ); + + const delivery = await this.prisma.webhookDelivery.findUnique({ + where: { id: webhookDeliveryId }, + }); + + if (!delivery) { + throw new Error(`Webhook delivery record ${webhookDeliveryId} not found`); + } + + if (delivery.status === 'sent') { + this.logger.warn(`Webhook delivery record ${webhookDeliveryId} already marked sent. Skipping.`); + return; + } + + await this.prisma.webhookDelivery.update({ + where: { id: webhookDeliveryId }, + data: { lastAttemptAt: new Date() }, + }); + + const payloadObj = JSON.parse(delivery.payload); + // Include deliveryId and timestamp as required by WebhookHmacGuard + const signedPayload = { + ...payloadObj, + deliveryId: delivery.id, + timestamp: new Date().toISOString(), + }; + + const rawBody = JSON.stringify(signedPayload); + const signature = this.hmacService.sign(rawBody); + + const startTime = Date.now(); + + try { + const response = await firstValueFrom( + this.httpService.post(delivery.url, signedPayload, { + headers: { + 'Content-Type': 'application/json', + 'x-webhook-signature': signature, + }, + timeout: 10000, // 10s timeout + }), + ); + + const durationSec = (Date.now() - startTime) / 1000; + this.metricsService.recordWebhookDeliveryDuration('verification_result', durationSec); + + await this.prisma.webhookDelivery.update({ + where: { id: webhookDeliveryId }, + data: { + status: 'sent', + sentAt: new Date(), + retryCount: job.attemptsMade, + }, + }); + + this.logger.log(`Webhook delivery ${webhookDeliveryId} sent successfully to ${delivery.url}`); + return { success: true, status: response.status }; + } catch (error: any) { + const errorMsg = error.response + ? `Status ${error.response.status}: ${JSON.stringify(error.response.data)}` + : error.message; + + this.logger.error(`Webhook delivery ${webhookDeliveryId} failed: ${errorMsg}`); + + // Save intermediate failure state + await this.prisma.webhookDelivery.update({ + where: { id: webhookDeliveryId }, + data: { + retryCount: job.attemptsMade + 1, + lastError: errorMsg.slice(0, 500), + }, + }); + + const maxAttempts = job.opts.attempts || 1; + if (job.attemptsMade + 1 < maxAttempts) { + this.metricsService.incrementWebhookRetry('verification_result', errorMsg.slice(0, 80)); + } + + throw new Error(errorMsg); + } + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.log(`Webhook job ${job.id} completed`); + } + + @OnWorkerEvent('failed') + async onFailed(job: Job<{ webhookDeliveryId: string }> | undefined, error: Error) { + if (job) { + this.logger.error( + `Webhook job ${job.id} failed after maximum attempts: ${error.message}`, + ); + + this.metricsService.incrementCallbackFailure('webhook_delivery', error.message.slice(0, 80)); + + // Update delivery record to failed + try { + await this.prisma.webhookDelivery.update({ + where: { id: job.data.webhookDeliveryId }, + data: { status: 'failed' }, + }); + } catch (err) { + this.logger.error(`Failed to update webhook delivery status to failed: ${err}`); + } + + await this.dlqService.moveToDlq('webhooks', job, error); + } + } +} diff --git a/app/backend/src/verification/webhook.service.spec.ts b/app/backend/src/verification/webhook.service.spec.ts new file mode 100644 index 00000000..4a3ebc3f --- /dev/null +++ b/app/backend/src/verification/webhook.service.spec.ts @@ -0,0 +1,148 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getQueueToken } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { NotFoundException } from '@nestjs/common'; +import { WebhookService } from './webhook.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('WebhookService', () => { + let service: WebhookService; + let prismaService: PrismaService; + let mockQueue: { + add: jest.Mock; + }; + + const mockDelivery = { + id: 'delivery-123', + url: 'http://localhost:3002/webhook', + payload: JSON.stringify({ claimId: 'claim-123' }), + status: 'pending', + entityId: 'claim-123', + entityType: 'claim', + retryCount: 0, + lastError: null, + }; + + beforeEach(async () => { + mockQueue = { + add: jest.fn().mockResolvedValue({ id: 'job-123' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookService, + { + provide: getQueueToken('webhooks'), + useValue: mockQueue, + }, + { + provide: PrismaService, + useValue: { + webhookDelivery: { + create: jest.fn().mockResolvedValue(mockDelivery), + findUnique: jest.fn(), + update: jest.fn(), + }, + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const config: Record = { + VERIFICATION_WEBHOOK_URL: 'http://localhost:3002/webhook', + WEBHOOK_MAX_ATTEMPTS: '5', + WEBHOOK_BACKOFF_DELAY_MS: '5000', + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + service = module.get(WebhookService); + prismaService = module.get(PrismaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enqueueWebhook', () => { + it('should create database record and enqueue job', async () => { + const claimId = 'claim-123'; + const status = 'verified'; + const result = { score: 0.9, confidence: 0.95, details: { factors: [], riskLevel: 'low' } }; + + await service.enqueueWebhook(claimId, status, result); + + expect(prismaService.webhookDelivery.create).toHaveBeenCalledWith({ + data: { + url: 'http://localhost:3002/webhook', + payload: expect.any(String), + status: 'pending', + entityId: claimId, + entityType: 'claim', + }, + }); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'deliver', + { webhookDeliveryId: mockDelivery.id }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000, + }, + }, + ); + }); + }); + + describe('replayWebhook', () => { + it('should reset database record status and enqueue job', async () => { + jest.spyOn(prismaService.webhookDelivery, 'findUnique').mockResolvedValue(mockDelivery as any); + jest.spyOn(prismaService.webhookDelivery, 'update').mockResolvedValue({ + ...mockDelivery, + status: 'pending', + } as any); + + const result = await service.replayWebhook(mockDelivery.id); + + expect(prismaService.webhookDelivery.findUnique).toHaveBeenCalledWith({ + where: { id: mockDelivery.id }, + }); + + expect(prismaService.webhookDelivery.update).toHaveBeenCalledWith({ + where: { id: mockDelivery.id }, + data: { + status: 'pending', + retryCount: 0, + lastError: null, + }, + }); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'deliver', + { webhookDeliveryId: mockDelivery.id }, + { + attempts: 5, + backoff: { + type: 'exponential', + delay: 5000, + }, + }, + ); + + expect(result.status).toBe('pending'); + }); + + it('should throw NotFoundException if delivery record not found', async () => { + jest.spyOn(prismaService.webhookDelivery, 'findUnique').mockResolvedValue(null); + + await expect(service.replayWebhook('invalid-id')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/app/backend/src/verification/webhook.service.ts b/app/backend/src/verification/webhook.service.ts new file mode 100644 index 00000000..6f5d4167 --- /dev/null +++ b/app/backend/src/verification/webhook.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + private readonly webhookUrl: string; + + constructor( + @InjectQueue('webhooks') private readonly webhooksQueue: Queue, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) { + this.webhookUrl = + this.configService.get('VERIFICATION_WEBHOOK_URL') || + 'http://localhost:3002/webhook'; + } + + /** + * Enqueue a verification result to be sent via webhook. + */ + async enqueueWebhook(claimId: string, status: string, result: any): Promise { + const payload = { + event: 'verification.completed', + claimId, + status, + score: result.score, + confidence: result.confidence, + details: result.details, + }; + + const delivery = await this.prisma.webhookDelivery.create({ + data: { + url: this.webhookUrl, + payload: JSON.stringify(payload), + status: 'pending', + entityId: claimId, + entityType: 'claim', + }, + }); + + const attempts = parseInt(this.configService.get('WEBHOOK_MAX_ATTEMPTS') || '5', 10); + const delay = parseInt(this.configService.get('WEBHOOK_BACKOFF_DELAY_MS') || '5000', 10); + + await this.webhooksQueue.add( + 'deliver', + { webhookDeliveryId: delivery.id }, + { + attempts, + backoff: { + type: 'exponential', + delay, + }, + }, + ); + + this.logger.log( + `Enqueued webhook delivery job ${delivery.id} for claim ${claimId} to ${this.webhookUrl}`, + ); + } + + /** + * Replay a failed webhook delivery by ID. + */ + async replayWebhook(id: string) { + const delivery = await this.prisma.webhookDelivery.findUnique({ + where: { id }, + }); + + if (!delivery) { + throw new NotFoundException(`Webhook delivery with ID ${id} not found`); + } + + // Reset status to pending + const updated = await this.prisma.webhookDelivery.update({ + where: { id }, + data: { + status: 'pending', + retryCount: 0, + lastError: null, + }, + }); + + const attempts = parseInt(this.configService.get('WEBHOOK_MAX_ATTEMPTS') || '5', 10); + const delay = parseInt(this.configService.get('WEBHOOK_BACKOFF_DELAY_MS') || '5000', 10); + + await this.webhooksQueue.add( + 'deliver', + { webhookDeliveryId: updated.id }, + { + attempts, + backoff: { + type: 'exponential', + delay, + }, + }, + ); + + this.logger.log( + `Replayed webhook delivery job ${updated.id} for entity ${updated.entityId} to ${updated.url}`, + ); + + return updated; + } +} diff --git a/app/backend/test/jest-e2e.json b/app/backend/test/jest-e2e.json index 988edaeb..3e8d8151 100644 --- a/app/backend/test/jest-e2e.json +++ b/app/backend/test/jest-e2e.json @@ -8,6 +8,7 @@ }, "moduleNameMapper": { "^src/(.*)$": "/src/$1", + "^cache/(.*)$": "/cache/$1", "^@stellar/stellar-sdk$": "/test/mocks/stellar-sdk.mock.ts", "^openai$": "/test/mocks/openai.mock.ts" } diff --git a/app/backend/test/webhook-e2e.spec.ts b/app/backend/test/webhook-e2e.spec.ts new file mode 100644 index 00000000..663713c1 --- /dev/null +++ b/app/backend/test/webhook-e2e.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { HmacService } from '../src/common/hmac/hmac.service'; +import request from 'supertest'; +import http from 'http'; +import { ClaimStatus } from '@prisma/client'; +import { getQueueToken } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +describe('Webhook Delivery E2E', () => { + let app: INestApplication; + let prisma: PrismaService; + let hmacService: HmacService; + let _webhooksQueue: Queue; + let server: http.Server; + let receivedRequests: Array<{ headers: http.IncomingHttpHeaders; body: any }> = []; + let serverPort: number; + + beforeAll(async () => { + // Start local server to receive webhooks + server = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + receivedRequests.push({ + headers: req.headers, + body: body ? JSON.parse(body) : null, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ received: true })); + }); + }); + + await new Promise((resolve) => { + server.listen(0, () => { + const address = server.address(); + serverPort = typeof address === 'string' ? 3002 : address?.port || 3002; + resolve(); + }); + }); + + process.env.VERIFICATION_WEBHOOK_URL = `http://localhost:${serverPort}/webhook`; + process.env.WEBHOOK_MAX_ATTEMPTS = '2'; + process.env.WEBHOOK_BACKOFF_DELAY_MS = '1000'; + process.env.WEBHOOK_SECRET = 'test-webhook-secret-123'; + process.env.API_KEY = 'test-api-key-123'; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + prefix: 'v', + }); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + + prisma = moduleFixture.get(PrismaService); + hmacService = moduleFixture.get(HmacService); + _webhooksQueue = moduleFixture.get(getQueueToken('webhooks')); + }); + + afterAll(async () => { + await app.close(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + beforeEach(() => { + receivedRequests = []; + }); + + it('should deliver claim verification result via signed webhook', async () => { + // 1. Create a test campaign and claim + const campaign = await prisma.campaign.create({ + data: { + name: 'Webhook E2E Campaign', + budget: 1000, + status: 'active', + }, + }); + + const claim = await prisma.claim.create({ + data: { + campaignId: campaign.id, + amount: 100.0, + recipientRef: 'webhook-user', + evidenceRef: 'http://localhost/evidence.png', + status: ClaimStatus.requested, + }, + }); + + // 2. Enqueue and process verification + const response = await request(app.getHttpServer()) + .post(`/api/v1/verification/claims/${claim.id}/enqueue`) + .set('x-api-key', 'test-api-key-123') + .expect(202); + + expect(response.body).toHaveProperty('jobId'); + + // 3. Wait for the queue processing and webhook delivery + // We poll the db until WebhookDelivery record is created and sent + let deliveryRecord: any = null; + for (let i = 0; i < 20; i++) { + deliveryRecord = await prisma.webhookDelivery.findFirst({ + where: { entityId: claim.id }, + }); + if (deliveryRecord && deliveryRecord.status === 'sent') { + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + expect(deliveryRecord).toBeDefined(); + expect(deliveryRecord.status).toBe('sent'); + + // 4. Verify received webhook request + expect(receivedRequests.length).toBeGreaterThanOrEqual(1); + const webhookReq = receivedRequests[0]; + + // Check HMAC signature validation + const receivedSignature = webhookReq.headers['x-webhook-signature'] as string; + expect(receivedSignature).toBeDefined(); + + const rawBody = JSON.stringify(webhookReq.body); + const isValid = hmacService.verify(rawBody, receivedSignature); + expect(isValid).toBe(true); + + // Verify payload schema + expect(webhookReq.body).toEqual(expect.objectContaining({ + event: 'verification.completed', + claimId: claim.id, + deliveryId: deliveryRecord.id, + timestamp: expect.any(String), + })); + + // Clean up + await prisma.claim.delete({ where: { id: claim.id } }); + await prisma.campaign.delete({ where: { id: campaign.id } }); + }); + + it('should support manual replay of delivery records', async () => { + // 1. Create a dummy delivery record marked as failed + const failedDelivery = await prisma.webhookDelivery.create({ + data: { + url: `http://localhost:${serverPort}/webhook-replay`, + payload: JSON.stringify({ event: 'test.replay', entityId: 'dummy-1' }), + status: 'failed', + entityId: 'dummy-1', + entityType: 'claim', + retryCount: 2, + lastError: 'Simulated failure', + }, + }); + + // 2. Trigger replay via controller endpoint + const response = await request(app.getHttpServer()) + .post(`/api/v1/verification/webhooks/${failedDelivery.id}/replay`) + .set('x-api-key', 'test-api-key-123') + .expect(201); + + expect(response.body.status).toBe('pending'); + expect(response.body.retryCount).toBe(0); + + // 3. Wait for the delivery to be retried and marked sent + let replayedRecord: any = null; + for (let i = 0; i < 20; i++) { + replayedRecord = await prisma.webhookDelivery.findUnique({ + where: { id: failedDelivery.id }, + }); + if (replayedRecord && replayedRecord.status === 'sent') { + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + expect(replayedRecord.status).toBe('sent'); + expect(receivedRequests.length).toBeGreaterThanOrEqual(1); + + // Clean up + await prisma.webhookDelivery.delete({ where: { id: failedDelivery.id } }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d78ee237..0d2f8860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@nestjs/swagger': specifier: ^11.2.6 - version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) body-parser: specifier: ^2.2.2 version: 2.2.2 @@ -53,7 +53,7 @@ importers: version: 0.3.7 jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + version: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) npm: specifier: ^11.10.1 version: 11.12.0 @@ -65,7 +65,7 @@ importers: version: 7.2.2 ts-jest: specifier: ^29.4.11 - version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)))(typescript@6.0.3) + version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)))(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -74,16 +74,16 @@ importers: dependencies: '@liaoliaots/nestjs-redis': specifier: ^10.0.0 - version: 10.0.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1) + version: 10.0.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(ioredis@5.10.1) '@nestjs/axios': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2) '@nestjs/bull': specifier: ^11.0.4 - version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bull@4.16.5) + version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bull@4.16.5) '@nestjs/bullmq': specifier: ^11.0.4 - version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.71.0) + version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.0) '@nestjs/common': specifier: ^11.0.1 version: 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -98,16 +98,16 @@ importers: version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) '@nestjs/schedule': specifier: ^5.0.1 - version: 5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + version: 5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) '@nestjs/swagger': specifier: ^11.2.5 - version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^11.0.0 - version: 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/throttler': specifier: ^6.5.0 - version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) @@ -177,13 +177,13 @@ importers: version: 9.39.4 '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.16(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15) + version: 11.0.16(@swc/core@1.15.30)(@types/node@22.19.15) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -243,10 +243,10 @@ importers: version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.2 - version: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))) + version: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30)) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -303,7 +303,7 @@ importers: version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-intl: specifier: ^4.9.1 - version: 4.9.1(@swc/helpers@0.5.15)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 4.9.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -373,7 +373,7 @@ importers: version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.15.30)(@types/node@20.19.37)(typescript@5.9.3) typescript: specifier: ^5 version: 5.9.3 @@ -10822,7 +10822,7 @@ snapshots: jest-util: 30.4.1 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -10921,9 +10921,9 @@ snapshots: jest-watcher: 30.3.0 pretty-format: 30.3.0 slash: 3.0.0 + strip-ansi: 6.0.1 transitivePeerDependencies: - babel-plugin-macros - - esbuild-register - supports-color - ts-node @@ -10943,7 +10943,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + jest-config: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -11383,7 +11383,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@liaoliaots/nestjs-redis@10.0.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)': + '@liaoliaots/nestjs-redis@10.0.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(ioredis@5.10.1)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11432,29 +11432,29 @@ snapshots: axios: 1.13.6 rxjs: 7.8.2 - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bull@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bull@4.16.5)': + '@nestjs/bull@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bull@4.16.5)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) bull: 4.16.5 tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.71.0)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.0)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.71.0 tslib: 2.8.1 - '@nestjs/cli@11.0.16(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)': + '@nestjs/cli@11.0.16(@swc/core@1.15.30)(@types/node@22.19.15)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) @@ -11465,17 +11465,17 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30)) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15)) + webpack: 5.104.1(@swc/core@1.15.30) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 transitivePeerDependencies: - '@types/node' - esbuild @@ -11539,7 +11539,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/schedule@5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@nestjs/schedule@5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11556,7 +11556,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11571,7 +11571,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11583,7 +11583,7 @@ snapshots: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2) '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) - '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17))': + '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11591,7 +11591,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) - '@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -12563,7 +12563,7 @@ snapshots: '@swc/core-win32-x64-msvc@1.15.30': optional: true - '@swc/core@1.15.30(@swc/helpers@0.5.15)': + '@swc/core@1.15.30': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.26 @@ -12580,7 +12580,6 @@ snapshots: '@swc/core-win32-arm64-msvc': 1.15.30 '@swc/core-win32-ia32-msvc': 1.15.30 '@swc/core-win32-x64-msvc': 1.15.30 - '@swc/helpers': 0.5.15 '@swc/counter@0.1.3': {} @@ -14317,13 +14316,28 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-jest@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)): + create-jest@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -14714,7 +14728,7 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) @@ -14758,6 +14772,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -14773,11 +14802,21 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) @@ -14804,7 +14843,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -15410,7 +15449,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30)): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 @@ -15425,7 +15464,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15)) + webpack: 5.104.1(@swc/core@1.15.30) form-data@4.0.5: dependencies: @@ -16152,16 +16191,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)): + jest-cli@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + create-jest: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -16196,7 +16235,8 @@ snapshots: '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 - exit-x: 0.2.2 + create-jest: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) + exit: 0.1.2 import-local: 3.2.0 jest-config: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) jest-util: 30.3.0 @@ -16205,19 +16245,18 @@ snapshots: transitivePeerDependencies: - '@types/node' - babel-plugin-macros - - esbuild-register - supports-color - ts-node jest-cli@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)): dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + '@jest/core': 30.4.2(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + jest-config: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.2 @@ -16228,7 +16267,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -16254,7 +16293,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.15 - ts-node: 10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -16385,7 +16424,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)): + jest-config@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -16412,7 +16451,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 25.9.1 - ts-node: 10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3) + ts-node: 10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -17145,12 +17184,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)): + jest@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3)) + jest-cli: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17178,16 +17217,15 @@ snapshots: transitivePeerDependencies: - '@types/node' - babel-plugin-macros - - esbuild-register - supports-color - ts-node jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)): dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + '@jest/core': 30.4.2(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)) + jest-cli: 30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30)(@types/node@25.9.1)(typescript@6.0.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17976,11 +18014,11 @@ snapshots: next-intl-swc-plugin-extractor@4.9.1: {} - next-intl@4.9.1(@swc/helpers@0.5.15)(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + next-intl@4.9.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.8.3 '@parcel/watcher': 2.5.6 - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 icu-minify: 4.9.1 negotiator: 1.0.0 next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -19533,15 +19571,15 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.4.0(@swc/core@1.15.30(@swc/helpers@0.5.15))(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))): + terser-webpack-plugin@5.4.0(@swc/core@1.15.30)(webpack@5.104.1(@swc/core@1.15.30)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15)) + webpack: 5.104.1(@swc/core@1.15.30) optionalDependencies: - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 terser@5.46.1: dependencies: @@ -19659,7 +19697,7 @@ snapshots: '@babel/core': 7.29.0 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 30.4.1(@babel/core@7.29.0) jest-util: 30.4.1 ts-jest@29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@6.0.3)))(typescript@6.0.3): @@ -19679,7 +19717,7 @@ snapshots: '@babel/core': 7.29.0 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 30.4.1(@babel/core@7.29.0) jest-util: 30.4.1 ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3): @@ -19719,10 +19757,10 @@ snapshots: '@babel/core': 7.29.0 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - babel-jest: 29.7.0(@babel/core@7.29.0) + babel-jest: 30.4.1(@babel/core@7.29.0) jest-util: 30.4.1 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))): + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.30)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.20.1 @@ -19730,9 +19768,9 @@ snapshots: semver: 7.7.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15)) + webpack: 5.104.1(@swc/core@1.15.30) - ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.30)(@types/node@20.19.37)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -19750,9 +19788,9 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 - ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@22.19.15)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.30)(@types/node@22.19.15)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -19770,7 +19808,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3): dependencies: @@ -19811,7 +19849,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.15.30(@swc/helpers@0.5.15) + '@swc/core': 1.15.30 optional: true tsconfig-paths-webpack-plugin@4.2.0: @@ -20123,7 +20161,7 @@ snapshots: webpack-sources@3.3.4: {} - webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15)): + webpack@5.104.1(@swc/core@1.15.30): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -20147,7 +20185,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.4.0(@swc/core@1.15.30(@swc/helpers@0.5.15))(webpack@5.104.1(@swc/core@1.15.30(@swc/helpers@0.5.15))) + terser-webpack-plugin: 5.4.0(@swc/core@1.15.30)(webpack@5.104.1(@swc/core@1.15.30)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: