diff --git a/.biomeignore b/.biomeignore new file mode 100644 index 0000000000..41a49ce016 --- /dev/null +++ b/.biomeignore @@ -0,0 +1,58 @@ +# Archivos y carpetas que Biome debe ignorar + +# (adicionales a los del .gitignore que ya respeta automáticamente) + +# Build outputs + +dist/ +build/ +.next/ +out/ + +# Dependencies + +node_modules/ +.pnp/ +.pnp.js + +# Testing + +coverage/ +.nyc_output/ + +# Database & migrations + +drizzle/ +migrations/ + +# Logs + +\*.log +logs/ + +# Environment files + +.env +.env.local +.env.\*.local + +# Editor + +.vscode/ +.idea/ +_.swp +_.swo + +# OS + +.DS_Store +Thumbs.db + +# Observability configs + +observability/ + +# Generated files + +**/\*.generated.ts +**/\*.generated.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..300d6de016 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +.env +.vscode +.idea +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..66c8486592 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Environment Variables +# Copy this file to .env and update with your values + +# Database +TRANSACTION_DB_URL=postgresql://postgres:postgres@localhost:5432/postgres +ANTIFRAUD_DB_URL=postgresql://postgres:postgres@localhost:5432/postgres + +# Kafka +KAFKA_BROKER=localhost:9092 + +# Services +PORT=3000 +ANTI_FRAUD_PORT=3001 + +# Application +SERVICE_NAME=transaction-service +SERVICE_VERSION=1.0.0 +NODE_ENV=development + +# Redis (optional) +REDIS_URL=redis://localhost:6379 + +# OpenTelemetry +# Para Docker usar: http://tempo:4318 +# Para ejecución local usar: http://localhost:4318 +OTEL_ENABLED=true +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +OTEL_SAMPLING_RATIO=1.0 + +# Logging +LOG_LEVEL=info + +# Sentry (optional) +SENTRY_ENABLED=false +SENTRY_DSN= diff --git a/.gitignore b/.gitignore index 67045665db..afdf1b0364 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ typings/ # dotenv environment variables file .env .env.test +docker/.env # parcel-bundler cache (https://parceljs.org/) .cache @@ -102,3 +103,15 @@ dist # TernJS port file .tern-port + +# Drizzle +drizzle/ + +# IDE +.vscode/ +.idea/ + +# AI Tools +.claude/ +.cursor/ +.ai/ diff --git a/README.md b/README.md index b067a71026..65bd4931f1 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,298 @@ -# Yape Code Challenge :rocket: +# Sistema de Transacciones - Yape Code Challenge -Our code challenge will let you marvel us with your Jedi coding skills :smile:. +Sistema de procesamiento de transacciones con validación anti-fraude usando microservicios, arquitectura hexagonal y comunicación event-driven. -Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !! +[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) +[![NestJS](https://img.shields.io/badge/NestJS-11-red.svg)](https://nestjs.com/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-14-blue.svg)](https://www.postgresql.org/) +[![Kafka](https://img.shields.io/badge/Apache%20Kafka-2.8-orange.svg)](https://kafka.apache.org/) +[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) +## Índice + +- [Inicio Rápido](#inicio-rápido) +- [Arquitectura](#arquitectura) +- [Documentación](#documentación) +- [Testing](#testing) +- [Comandos](#comandos-principales) - [Problem](#problem) -- [Tech Stack](#tech_stack) -- [Send us your challenge](#send_us_your_challenge) +- [Tech Stack](#tech-stack) +- [Send us your challenge](#send-us-your-challenge) + +--- + +## Inicio Rápido + +### Prerrequisitos +- **Node.js 20+** +- **Docker & Docker Compose** +- **PostgreSQL 14+** (incluido en Docker Compose) + +### Instalación y Ejecución + +```bash +# 1. Clonar el repositorio +git clone +cd app-nodejs-codechallenge + +# 2. Instalar dependencias +npm install + +# 3. Configurar variables de entorno +cp .env.example .env +# Editar .env con tus configuraciones + +# 4. Levantar infraestructura y servicios (Docker) +# Esto inicializará la base de datos y cargará las reglas automáticamente +docker-compose up --build -d + +# 5. (Opcional) Si ejecutas localmente sin Docker Compose: +# npm run db:push +# npm run seed:fraud-rules +# npm run start:dev +``` + +### Verificar Instalación +```bash +# Crear una transacción de prueba (UUIDs válidos requeridos) +curl -X POST http://localhost:3000/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000", + "accountExternalIdCredit": "550e8400-e29b-41d4-a716-446655440001", + "tranferTypeId": 1, + "value": 500 + }' +``` + +### Servicios Disponibles +| Servicio | URL | Descripción | +|----------|-----|-------------| +| **Transaction Service** | http://localhost:3000 | API de transacciones | +| **Anti-Fraud Service** | http://localhost:3001 | Validación anti-fraude | +| **Kafka UI** | http://localhost:9000 | Monitor de mensajes (Kafka 2.5/5.5.3) | +| **Grafana** | http://localhost:3002 | Dashboards (admin/admin) | -# Problem +--- -Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status. -For now, we have only three transaction statuses: +## Arquitectura + +### Vista General +![Arquitectura](docs/diagrams/architecture-overview-jsoncrack.jpeg) + +### Microservicios +- **Transaction Service** (3000): Gestión de transacciones + eventos Kafka. +- **Anti-Fraud Service** (3001): Validación de reglas de negocio. Incluye la **regla obligatoria de umbral de monto (>1000)** y un motor extensible para reglas adicionales (demo). + +### Patrones Implementados +- **Hexagonal Architecture**: Domain, Application, Infrastructure, Presentation +- **Domain-Driven Design**: Bounded contexts y ubiquitous language +- **Event-Driven**: Kafka para comunicación asíncrona +- **Repository Pattern**: Abstracción de persistencia + +### Stack Técnico +| Categoría | Tecnología | +|-----------|-----------| +| Framework | NestJS 11 + TypeScript | +| Base de Datos | PostgreSQL 14 + Drizzle ORM | +| Mensajería | Apache Kafka 2.8 | +| Cache | Redis | +| Observabilidad | OpenTelemetry + Grafana + Tempo | +| DevOps | Docker + Docker Compose | + +**Ver:** [Arquitectura detallada en docs/GUIDE.md](docs/GUIDE.md) + +--- + +## Documentación + +| Documento | Descripción | +|-----------|-------------| +| **[INDEX.md](docs/INDEX.md)** | Índice de documentación técnica | +| **[GUIDE.md](docs/GUIDE.md)** | Arquitectura hexagonal y patrones de diseño | +| **[FRAUD_RULES.md](docs/FRAUD_RULES.md)** | Motor de reglas anti-fraude configurables | +| **[API-TESTING.md](docs/API-TESTING.md)** | Guía de testing con curl y scripts | +| **[Postman Collection](docs/collection/RETO%20YAPE.postman_collection.json)** | Colección de Postman para pruebas de API | + +--- + +## Testing + +```bash +# Tests unitarios +npm test # Ejecutar todos los tests +npm run test:cov # Con coverage +npm run test:watch # Modo watch + +# Test de integración (API) +npm run test:api # Script bash para probar APIs + +# Tests E2E +npm run test:e2e +``` + +**Pruebas manuales con curl:** +```bash +# Consultar transacción (Verificar estado) +curl http://localhost:3000/transactions/{transactionExternalId} + +# Crear transacción (Usar UUIDs reales) +curl -X POST http://localhost:3000/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000", + "accountExternalIdCredit": "550e8400-e29b-41d4-a716-446655440001", + "tranferTypeId": 1, + "value": 500 + }' +``` + +--- + +## Comandos Principales +```bash +# Desarrollo +npm run start:dev # Iniciar ambos servicios +npm run start:transaction:dev # Solo Transaction Service +npm run start:anti-fraud:dev # Solo Anti-Fraud Service + +# Base de datos +npm run db:push # Aplicar schema +npm run db:studio # GUI de base de datos +npm run seed:fraud-rules # Cargar reglas anti-fraude + +# Calidad de código +npm run check:fix # Lint + format (automático) +``` + +### Estructura del Proyecto +``` +app-nodejs-codechallenge/ +├── apps/ +│ ├── transaction-service/ +│ │ └── src/ +│ │ ├── application/ +│ │ │ └── use-cases/ +│ │ ├── config/ +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ ├── ports/ +│ │ │ ├── repositories/ +│ │ │ └── value-objects/ +│ │ ├── infrastructure/ +│ │ │ ├── database/ +│ │ │ ├── messaging/ +│ │ │ └── repositories/ +│ │ └── presentation/ +│ │ ├── controllers/ +│ │ └── dtos/ +│ └── anti-fraud-service/ +│ ├── scripts/ +│ └── src/ +│ ├── application/ +│ │ └── use-cases/ +│ ├── config/ +│ ├── domain/ +│ │ ├── entities/ +│ │ ├── ports/ +│ │ ├── repositories/ +│ │ ├── services/ +│ │ └── types/ +│ └── infrastructure/ +│ ├── database/ +│ ├── mappers/ +│ ├── messaging/ +│ └── repositories/ +├── libs/ +│ ├── common/ +│ │ └── src/ +│ │ ├── database/ +│ │ │ └── schemas/ +│ │ ├── events/ +│ │ ├── exceptions/ +│ │ │ └── filters/ +│ │ ├── interceptors/ +│ │ ├── kafka/ +│ │ ├── redis/ +│ │ └── types/ +│ └── observability/ +│ └── src/ +│ ├── adapters/ +│ ├── kafka/ +│ ├── logging/ +│ ├── ports/ +│ └── tracing/ +├── config/ +│ └── database/ +├── drizzle/ +│ └── meta/ +├── docs/ +│ ├── collection/ +│ └── diagrams/ +├── observability/ +│ ├── grafana/ +│ │ ├── dashboards/ +│ │ └── provisioning/ +│ └── tempo/ +├── test/ +│ └── api/ +└── docker-compose.yml +``` + +### Variables de Entorno +Ver [.env.example](.env.example) para configuración completa. + +--- + +# El Problema + +Cada vez que se crea una transacción financiera, esta debe ser validada por nuestro microservicio anti-fraude y luego el mismo servicio envía un mensaje de vuelta para actualizar el estado de la transacción. + +Por ahora, solo tenemos tres estados de transacción:
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. +
  7. pending (pendiente)
  8. +
  9. approved (aprobado)
  10. +
  11. rejected (rechazado)
-Every transaction with a value greater than 1000 should be rejected. +Cada transacción con un valor superior a 1000 debe ser rechazada. ```mermaid flowchart LR - Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)] - Transaction --Send transaction Created event--> Anti-Fraud - Anti-Fraud -- Send transaction Status Approved event--> Transaction - Anti-Fraud -- Send transaction Status Rejected event--> Transaction - Transaction -- Update transaction Status event--> transactionDatabase[(Database)] + Transaction -- Guarda Transacción con estado pending --> transactionDatabase[(Base de Datos)] + Transaction --Envía evento TransactionCreated--> Anti-Fraud + Anti-Fraud -- Envía evento TransactionApproved--> Transaction + Anti-Fraud -- Envía evento TransactionRejected--> Transaction + Transaction -- Actualiza estado de transacción --> transactionDatabase[(Base de Datos)] ``` -# Tech Stack +# Stack Tecnológico -
    -
  1. Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
  2. -
  3. Any database
  4. -
  5. Kafka
  6. -
+| Categoría | Tecnología | +|-----------|-----------| +| **Backend** | Node.js 20+ · NestJS 11 · TypeScript | +| **Base de Datos** | PostgreSQL 14 · Drizzle ORM | +| **Mensajería** | Apache Kafka 2.5/5.5.3 · Redis | +| **Observabilidad** | OpenTelemetry · Grafana · Tempo · Pino · Sentry | +| **Testing** | Jest · Supertest · Biome | +| **DevOps** | Docker · Docker Compose | + +## APIs -We do provide a `Dockerfile` to help you get started with a dev environment. +**Transaction Service (3000):** +- `POST /transactions` - Crear transacción +- `GET /transactions/:transactionExternalId` - Obtener transacción por ID externo -You must have two resources: +**Anti-Fraud Service (3001):** +- Validación interna basada en reglas. -1. Resource to create a transaction that must containt: +**Eventos Kafka:** +- `transaction-created` → `transaction-approved/rejected` + +Se deben tener dos recursos principales: + +1. Recurso para crear una transacción que debe contener: ```json { @@ -53,7 +303,7 @@ You must have two resources: } ``` -2. Resource to retrieve a transaction +2. Recurso para recuperar una transacción: ```json { @@ -69,14 +319,33 @@ You must have two resources: } ``` -## Optional +## Opcionales (Consideraciones de Diseño) -You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement? +**Escenarios de Alto Volumen:** +- **Optimización de Escritura**: Kafka para ingesta asíncrona, PostgreSQL con índices optimizados. +- **Optimización de Lectura**: Cache con Redis, réplicas de lectura, patrón CQRS. +- **Escalabilidad**: Microservicios independientes, diseño sin estado (stateless). -You can use Graphql; +--- -# Send us your challenge +# Envíanos tu desafío -When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution. +Cuando termines tu desafío, después de hacer un fork del repositorio, **debes** abrir un pull request a nuestro repositorio. No hay limitaciones para la implementación, puedes seguir el paradigma de programación, la modularización y el estilo que sientas que es la solución más apropiada. If you have any questions, please let us know. + +--- + +## Notas de Implementación + +Implementación: +- **Regla de Negocio**: Se cumple con la validación de transacciones > 1000. Aunque el requerimiento era un monto fijo, se implementó mediante un motor de reglas configurable para demostrar extensibilidad. +- **Arquitectura**: Hexagonal + DDD. +- **Extras**: Se incluyen reglas adicionales de forma demostrativa (blacklist, límites por tipo, etc.) para mostrar cómo el sistema puede evolucionar. +- **Observabilidad**: Implementada con OpenTelemetry. +- **Tests**: Unitarios e integración incluidos. + +**Próximos pasos:** +1. Revisar [docs/INDEX.md](docs/INDEX.md) para documentación completa +2. Ejecutar `npm run test:api` para probar las APIs +3. Explorar Grafana en http://localhost:3002 diff --git a/apps/anti-fraud-service/Dockerfile b/apps/anti-fraud-service/Dockerfile new file mode 100644 index 0000000000..34172b456f --- /dev/null +++ b/apps/anti-fraud-service/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig*.json ./ +COPY nest-cli.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY apps/anti-fraud-service ./apps/anti-fraud-service +COPY libs ./libs + +# Build application +RUN npm run build:anti-fraud + +# Production image +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package*.json ./ +RUN npm install --only=production && npm cache clean --force + +# Copy built application from builder (maintain directory structure) +COPY --from=builder /app/dist ./dist + +# Expose port +EXPOSE 3001 + +# Start application +CMD ["node", "dist/apps/anti-fraud-service/apps/anti-fraud-service/src/main.js"] diff --git a/apps/anti-fraud-service/scripts/seed-default-rules.ts b/apps/anti-fraud-service/scripts/seed-default-rules.ts new file mode 100644 index 0000000000..0b2215aba2 --- /dev/null +++ b/apps/anti-fraud-service/scripts/seed-default-rules.ts @@ -0,0 +1,78 @@ +import 'dotenv/config'; +import { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { fraudRules } from '../src/infrastructure/database/fraud-rules.schema'; + +const DATABASE_URL = process.env.ANTIFRAUD_DB_URL || 'postgresql://postgres:postgres@localhost:5432/postgres'; + +async function seedDefaultRules() { + const client = postgres(DATABASE_URL); + const db = drizzle(client); + + console.log('🌱 Seeding default fraud rules...'); + + const defaultRules = [ + { + name: 'High Amount Transaction', + description: 'Reject transactions greater than 1000', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { + operator: 'gt', + value: 1000, + }, + action: FraudAction.REJECT, + priority: 100, + isActive: true, + }, + { + name: 'Very High Amount - Review', + description: 'Flag transactions greater than 5000 for review', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { + operator: 'gt', + value: 5000, + }, + action: FraudAction.REVIEW, + priority: 50, + isActive: false, // Desactivada por defecto (activar manualmente si se requiere) + }, + { + name: 'Weekend Transfer Limit', + description: 'Restrict high-value transfers on weekends', + ruleType: FraudRuleType.TIME_BASED, + condition: { + allowedHours: { start: 0, end: 23 }, + allowedDays: [1, 2, 3, 4, 5], // Lunes a viernes + }, + action: FraudAction.REJECT, + priority: 200, + isActive: false, // Desactivada por defecto + }, + { + name: 'Payment Type Limit', + description: 'Limit payment type transactions to 500', + ruleType: FraudRuleType.TRANSFER_TYPE_LIMIT, + condition: { + transferTypeId: 2, // PAYMENT + maxAmount: 500, + }, + action: FraudAction.REJECT, + priority: 150, + isActive: false, // Desactivada por defecto + }, + ]; + + for (const rule of defaultRules) { + await db.insert(fraudRules).values(rule); + console.log(`✅ Created rule: ${rule.name}`); + } + + console.log('🎉 Default rules seeded successfully!'); + await client.end(); +} + +seedDefaultRules().catch((error) => { + console.error('❌ Error seeding rules:', error); + process.exit(1); +}); diff --git a/apps/anti-fraud-service/scripts/tsconfig.json b/apps/anti-fraud-service/scripts/tsconfig.json new file mode 100644 index 0000000000..d57dc46593 --- /dev/null +++ b/apps/anti-fraud-service/scripts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../../dist/apps/anti-fraud-service/scripts" + }, + "include": ["./**/*", "../src/**/*"] +} diff --git a/apps/anti-fraud-service/src/anti-fraud.module.ts b/apps/anti-fraud-service/src/anti-fraud.module.ts new file mode 100644 index 0000000000..9ea6437ff3 --- /dev/null +++ b/apps/anti-fraud-service/src/anti-fraud.module.ts @@ -0,0 +1,88 @@ +import { DatabaseModule, KafkaModule } from '@app/common'; +import { LoggingModule, ObservabilityModule, TracingModule } from '@app/observability'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ValidateTransactionUseCase } from './application/use-cases/validate-transaction.use-case'; +import { VALIDATE_TRANSACTION_USE_CASE } from './domain/ports/input/validate-transaction.use-case.interface'; +import { EVENT_PUBLISHER } from './domain/ports/output/event-publisher.interface'; +import { FRAUD_RULE_REPOSITORY } from './domain/repositories/fraud-rule.repository.interface'; +import { RulesEngineService } from './domain/services/rules-engine.service'; +import { KafkaEventPublisher } from './infrastructure/messaging/kafka-event-publisher'; +import { TransactionCreatedConsumer } from './infrastructure/messaging/transaction-created.consumer'; +import { FraudRuleRepository } from './infrastructure/repositories/fraud-rule.repository'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule.forRootAsync({ + isGlobal: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const url = configService.get('ANTIFRAUD_DB_URL'); + if (!url) { + throw new Error('ANTIFRAUD_DB_URL environment variable is required'); + } + return { url }; + }, + }), + ObservabilityModule.forRoot({ + serviceName: 'anti-fraud-service', + enableSentry: process.env.SENTRY_ENABLED === 'true', + sentryDsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV || 'development', + }), + TracingModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + serviceName: 'anti-fraud-service', + serviceVersion: '1.0.0', + otlpEndpoint: config.get('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), + environment: config.get('NODE_ENV', 'development'), + sampling: parseFloat(config.get('OTEL_SAMPLING_RATIO', '1.0')), + enabled: config.get('OTEL_ENABLED', 'true') !== 'false', + }), + }), + + LoggingModule.forRoot('anti-fraud-service'), + KafkaModule.forRootAsync({ + isGlobal: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const kafkaBroker = configService.get('KAFKA_BROKER'); + if (!kafkaBroker) { + throw new Error('KAFKA_BROKER environment variable is required'); + } + return { + clientId: 'anti-fraud-service', + brokers: [kafkaBroker], + consumerConfig: { + groupId: 'anti-fraud-service-group-v2', + }, + }; + }, + }), + ], + controllers: [], + providers: [ + { + provide: VALIDATE_TRANSACTION_USE_CASE, + useClass: ValidateTransactionUseCase, + }, + + RulesEngineService, + { + provide: FRAUD_RULE_REPOSITORY, + useClass: FraudRuleRepository, + }, + + TransactionCreatedConsumer, + { + provide: EVENT_PUBLISHER, + useClass: KafkaEventPublisher, + }, + ], +}) +export class AntiFraudModule {} diff --git a/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.spec.ts b/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.spec.ts new file mode 100644 index 0000000000..31756068d7 --- /dev/null +++ b/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.spec.ts @@ -0,0 +1,456 @@ +import { FraudAction, type FraudRuleEvaluationResult } from '@app/common/types/fraud-rules.types'; +import type { LoggerService } from '@app/observability'; +import type { IEventPublisher } from '../../domain/ports/output/event-publisher.interface'; +import type { RulesEngineService } from '../../domain/services/rules-engine.service'; +import { ValidateTransactionUseCase } from './validate-transaction.use-case'; + +describe('ValidateTransactionUseCase', () => { + let useCase: ValidateTransactionUseCase; + let mockRulesEngine: jest.Mocked; + let mockLogger: jest.Mocked; + let mockEventPublisher: jest.Mocked; + + beforeEach(() => { + mockRulesEngine = { + evaluateTransaction: jest.fn(), + determineFinalAction: jest.fn(), + } as any; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + } as unknown as jest.Mocked; + + mockEventPublisher = { + publishResult: jest.fn(), + } as jest.Mocked; + + useCase = new ValidateTransactionUseCase(mockRulesEngine, mockLogger, mockEventPublisher); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const transactionData = { + transactionId: 'txn-123', + value: 500, + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + createdAt: new Date('2024-01-01T00:00:00Z'), + metadata: { + correlationId: 'correlation-123', + causationId: 'causation-456', + service: 'TransactionService', + }, + }; + + describe('when transaction is approved', () => { + beforeEach(() => { + const ruleResults: FraudRuleEvaluationResult[] = []; + mockRulesEngine.evaluateTransaction.mockResolvedValue(ruleResults); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.APPROVE, + matchedRules: [], + reasons: ['No fraud rules matched'], + }); + }); + + it('should evaluate transaction with rules engine', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockRulesEngine.evaluateTransaction).toHaveBeenCalledWith({ + transactionExternalId: transactionData.transactionId, + accountExternalIdDebit: transactionData.accountExternalIdDebit, + accountExternalIdCredit: transactionData.accountExternalIdCredit, + transferTypeId: transactionData.transferTypeId, + value: transactionData.value, + createdAt: transactionData.createdAt, + }); + }); + + it('should publish approved status', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockEventPublisher.publishResult).toHaveBeenCalledWith( + transactionData.transactionId, + 'approved', + ['No fraud rules matched'], + [], + transactionData.metadata.correlationId, + transactionData.metadata.causationId, + ); + }); + + it('should log approved transaction with log level', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'Transaction approve', + expect.objectContaining({ + correlationId: transactionData.metadata.correlationId, + requestId: transactionData.metadata.causationId, + }), + expect.objectContaining({ + transactionId: transactionData.transactionId, + action: FraudAction.APPROVE, + value: transactionData.value, + }), + ); + }); + }); + + describe('when transaction is rejected', () => { + const matchedRules: FraudRuleEvaluationResult[] = [ + { + ruleId: 'rule-1', + ruleName: 'High Value Reject', + matched: true, + action: FraudAction.REJECT, + reason: 'Amount exceeds threshold', + }, + ]; + + beforeEach(() => { + mockRulesEngine.evaluateTransaction.mockResolvedValue(matchedRules); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.REJECT, + matchedRules, + reasons: ['High Value Reject: Amount exceeds threshold'], + }); + }); + + it('should publish rejected status', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockEventPublisher.publishResult).toHaveBeenCalledWith( + transactionData.transactionId, + 'rejected', + ['High Value Reject: Amount exceeds threshold'], + ['High Value Reject'], + transactionData.metadata.correlationId, + transactionData.metadata.causationId, + ); + }); + + it('should log rejected transaction with warn level', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Transaction reject', + expect.objectContaining({ + correlationId: transactionData.metadata.correlationId, + }), + expect.objectContaining({ + transactionId: transactionData.transactionId, + action: FraudAction.REJECT, + matchedRules: ['High Value Reject'], + }), + ); + }); + }); + + describe('when transaction is flagged', () => { + const matchedRules: FraudRuleEvaluationResult[] = [ + { + ruleId: 'rule-2', + ruleName: 'Suspicious Pattern', + matched: true, + action: FraudAction.FLAG, + reason: 'Pattern detected', + }, + ]; + + beforeEach(() => { + mockRulesEngine.evaluateTransaction.mockResolvedValue(matchedRules); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.FLAG, + matchedRules, + reasons: ['Suspicious Pattern: Pattern detected'], + }); + }); + + it('should publish rejected status for FLAG action', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockEventPublisher.publishResult).toHaveBeenCalledWith( + transactionData.transactionId, + 'rejected', + ['Suspicious Pattern: Pattern detected'], + ['Suspicious Pattern'], + transactionData.metadata.correlationId, + transactionData.metadata.causationId, + ); + }); + + it('should log flagged transaction with log level', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'Transaction flag', + expect.any(Object), + expect.objectContaining({ + action: FraudAction.FLAG, + matchedRules: ['Suspicious Pattern'], + }), + ); + }); + }); + + describe('when transaction requires review', () => { + const matchedRules: FraudRuleEvaluationResult[] = [ + { + ruleId: 'rule-3', + ruleName: 'Manual Review Required', + matched: true, + action: FraudAction.REVIEW, + reason: 'Complex transaction', + }, + ]; + + beforeEach(() => { + mockRulesEngine.evaluateTransaction.mockResolvedValue(matchedRules); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.REVIEW, + matchedRules, + reasons: ['Manual Review Required: Complex transaction'], + }); + }); + + it('should publish rejected status for REVIEW action', async () => { + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockEventPublisher.publishResult).toHaveBeenCalledWith( + transactionData.transactionId, + 'rejected', + ['Manual Review Required: Complex transaction'], + ['Manual Review Required'], + transactionData.metadata.correlationId, + transactionData.metadata.causationId, + ); + }); + }); + + describe('logging', () => { + it('should log validation start', async () => { + mockRulesEngine.evaluateTransaction.mockResolvedValue([]); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.APPROVE, + matchedRules: [], + reasons: [], + }); + + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'Validating transaction', + expect.objectContaining({ + correlationId: transactionData.metadata.correlationId, + requestId: transactionData.metadata.causationId, + service: transactionData.metadata.service, + }), + { + transactionId: transactionData.transactionId, + value: transactionData.value, + transferTypeId: transactionData.transferTypeId, + }, + ); + }); + }); + + describe('context building', () => { + it('should build correct fraud evaluation context', async () => { + mockRulesEngine.evaluateTransaction.mockResolvedValue([]); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.APPROVE, + matchedRules: [], + reasons: [], + }); + + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockRulesEngine.evaluateTransaction).toHaveBeenCalledWith({ + transactionExternalId: transactionData.transactionId, + accountExternalIdDebit: transactionData.accountExternalIdDebit, + accountExternalIdCredit: transactionData.accountExternalIdCredit, + transferTypeId: transactionData.transferTypeId, + value: transactionData.value, + createdAt: transactionData.createdAt, + }); + }); + }); + + describe('error propagation', () => { + it('should propagate rules engine evaluation errors', async () => { + const error = new Error('Rules engine error'); + mockRulesEngine.evaluateTransaction.mockRejectedValue(error); + + await expect( + useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ), + ).rejects.toThrow('Rules engine error'); + }); + + it('should propagate event publisher errors', async () => { + mockRulesEngine.evaluateTransaction.mockResolvedValue([]); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.APPROVE, + matchedRules: [], + reasons: [], + }); + mockEventPublisher.publishResult.mockRejectedValue(new Error('Kafka error')); + + await expect( + useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ), + ).rejects.toThrow('Kafka error'); + }); + }); + + describe('multiple matched rules', () => { + it('should handle multiple matched rules correctly', async () => { + const matchedRules: FraudRuleEvaluationResult[] = [ + { + ruleId: 'rule-1', + ruleName: 'Rule 1', + matched: true, + action: FraudAction.FLAG, + reason: 'Reason 1', + }, + { + ruleId: 'rule-2', + ruleName: 'Rule 2', + matched: true, + action: FraudAction.REJECT, + reason: 'Reason 2', + }, + ]; + + mockRulesEngine.evaluateTransaction.mockResolvedValue(matchedRules); + mockRulesEngine.determineFinalAction.mockReturnValue({ + action: FraudAction.REJECT, + matchedRules, + reasons: ['Rule 1: Reason 1', 'Rule 2: Reason 2'], + }); + + await useCase.execute( + transactionData.transactionId, + transactionData.value, + transactionData.accountExternalIdDebit, + transactionData.accountExternalIdCredit, + transactionData.transferTypeId, + transactionData.createdAt, + transactionData.metadata, + ); + + expect(mockEventPublisher.publishResult).toHaveBeenCalledWith( + transactionData.transactionId, + 'rejected', + ['Rule 1: Reason 1', 'Rule 2: Reason 2'], + ['Rule 1', 'Rule 2'], + transactionData.metadata.correlationId, + transactionData.metadata.causationId, + ); + }); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.ts b/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.ts new file mode 100644 index 0000000000..7eb28a3297 --- /dev/null +++ b/apps/anti-fraud-service/src/application/use-cases/validate-transaction.use-case.ts @@ -0,0 +1,88 @@ +import { FraudAction, type FraudEvaluationContext } from '@app/common/types/fraud-rules.types'; +import { LoggerService } from '@app/observability'; +import { Inject, Injectable } from '@nestjs/common'; +import type { IValidateTransactionUseCase } from '../../domain/ports/input/validate-transaction.use-case.interface'; +import { EVENT_PUBLISHER, type IEventPublisher } from '../../domain/ports/output/event-publisher.interface'; +import { RulesEngineService } from '../../domain/services/rules-engine.service'; + +@Injectable() +export class ValidateTransactionUseCase implements IValidateTransactionUseCase { + constructor( + private readonly rulesEngine: RulesEngineService, + private readonly logger: LoggerService, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: IEventPublisher, + ) {} + + async execute( + transactionId: string, + value: number, + accountExternalIdDebit: string, + accountExternalIdCredit: string, + transferTypeId: number, + createdAt: Date, + metadata: { correlationId: string; causationId: string; service: string }, + ): Promise { + const requestContext = { + correlationId: metadata.correlationId, + requestId: metadata.causationId, + service: metadata.service, + }; + + this.logger.log('Validating transaction', requestContext, { + transactionId, + value, + transferTypeId, + }); + + const context: FraudEvaluationContext = { + transactionExternalId: transactionId, + accountExternalIdDebit, + accountExternalIdCredit, + transferTypeId, + value, + createdAt, + }; + + const ruleResults = await this.rulesEngine.evaluateTransaction(context); + const { action, matchedRules, reasons } = this.rulesEngine.determineFinalAction(ruleResults); + + let status: string; + switch (action) { + case FraudAction.REJECT: + case FraudAction.REVIEW: + case FraudAction.FLAG: + status = 'rejected'; + break; + default: + status = 'approved'; + } + + await this.eventPublisher.publishResult( + transactionId, + status, + reasons, + matchedRules.map((r) => r.ruleName), + metadata.correlationId, + metadata.causationId, + ); + + if (action === FraudAction.REJECT) { + this.logger.warn(`Transaction ${action.toLowerCase()}`, requestContext, { + transactionId, + action, + value, + matchedRules: matchedRules.map((r) => r.ruleName), + reasons, + }); + } else { + this.logger.log(`Transaction ${action.toLowerCase()}`, requestContext, { + transactionId, + action, + value, + matchedRules: matchedRules.map((r) => r.ruleName), + reasons, + }); + } + } +} diff --git a/apps/anti-fraud-service/src/config/instrument.ts b/apps/anti-fraud-service/src/config/instrument.ts new file mode 100644 index 0000000000..f3aeae11ee --- /dev/null +++ b/apps/anti-fraud-service/src/config/instrument.ts @@ -0,0 +1,30 @@ +require('dotenv').config(); + +import { initializeTelemetry } from '@app/observability'; + +initializeTelemetry({ + serviceName: 'anti-fraud-service', + serviceVersion: '1.0.0', + otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318', + environment: process.env.NODE_ENV || 'development', + sampling: Number.parseFloat(process.env.OTEL_SAMPLING_RATIO || '1.0'), + enabled: process.env.OTEL_ENABLED !== 'false', +}); + +console.log('✅ OpenTelemetry initialized for anti-fraud-service'); + +import * as Sentry from '@sentry/nestjs'; + +if (process.env.SENTRY_ENABLED === 'true') { + Sentry.init({ + dsn: + process.env.SENTRY_DSN || + 'https://355fd73ebe31d920ebd6c57263a8c28f@o4507779425435648.ingest.us.sentry.io/4510643730579456', + environment: process.env.NODE_ENV || 'development', + sendDefaultPii: true, + tracesSampleRate: 1, + integrations: [Sentry.captureConsoleIntegration()], + }); + + console.log('✅ Sentry initialized for anti-fraud-service'); +} diff --git a/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.spec.ts b/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.spec.ts new file mode 100644 index 0000000000..e88cdd007a --- /dev/null +++ b/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.spec.ts @@ -0,0 +1,63 @@ +import { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { FraudRule } from './fraud-rule.entity'; + +describe('FraudRule', () => { + describe('constructor', () => { + it('should create a fraud rule with all required properties', () => { + const rule = new FraudRule( + '1', + 'High Value Transaction', + 'Flag transactions over $1000', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: '>', value: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date('2024-01-01'), + new Date('2024-01-01'), + ); + + expect(rule.id).toBe('1'); + expect(rule.name).toBe('High Value Transaction'); + expect(rule.description).toBe('Flag transactions over $1000'); + expect(rule.ruleType).toBe(FraudRuleType.AMOUNT_THRESHOLD); + expect(rule.condition).toEqual({ operator: '>', value: 1000 }); + expect(rule.action).toBe(FraudAction.FLAG); + expect(rule.priority).toBe(1); + expect(rule.isActive).toBe(true); + expect(rule.createdAt).toEqual(new Date('2024-01-01')); + expect(rule.updatedAt).toEqual(new Date('2024-01-01')); + }); + }); + + describe('business methods', () => { + let rule: FraudRule; + + beforeEach(() => { + rule = new FraudRule( + '1', + 'Test Rule', + 'Test description', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: '>', value: 500 }, + FraudAction.REVIEW, + 1, + true, + new Date(), + new Date(), + ); + }); + + it('should be active', () => { + expect(rule.isActive).toBe(true); + }); + + it('should have correct priority', () => { + expect(rule.priority).toBe(1); + }); + + it('should have correct action', () => { + expect(rule.action).toBe(FraudAction.REVIEW); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.ts b/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.ts new file mode 100644 index 0000000000..8d655f2a9d --- /dev/null +++ b/apps/anti-fraud-service/src/domain/entities/fraud-rule.entity.ts @@ -0,0 +1,16 @@ +import type { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; + +export class FraudRule { + constructor( + public readonly id: string, + public readonly name: string, + public readonly description: string | null, + public readonly ruleType: FraudRuleType, + public readonly condition: any, + public readonly action: FraudAction, + public readonly priority: number, + public readonly isActive: boolean, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} +} diff --git a/apps/anti-fraud-service/src/domain/ports/input/validate-transaction.use-case.interface.ts b/apps/anti-fraud-service/src/domain/ports/input/validate-transaction.use-case.interface.ts new file mode 100644 index 0000000000..be37ad8266 --- /dev/null +++ b/apps/anti-fraud-service/src/domain/ports/input/validate-transaction.use-case.interface.ts @@ -0,0 +1,13 @@ +export interface IValidateTransactionUseCase { + execute( + transactionId: string, + value: number, + accountExternalIdDebit: string, + accountExternalIdCredit: string, + transferTypeId: number, + createdAt: Date, + metadata: { correlationId: string; causationId: string; service: string }, + ): Promise; +} + +export const VALIDATE_TRANSACTION_USE_CASE = 'VALIDATE_TRANSACTION_USE_CASE'; diff --git a/apps/anti-fraud-service/src/domain/ports/output/event-publisher.interface.ts b/apps/anti-fraud-service/src/domain/ports/output/event-publisher.interface.ts new file mode 100644 index 0000000000..da5fa339d7 --- /dev/null +++ b/apps/anti-fraud-service/src/domain/ports/output/event-publisher.interface.ts @@ -0,0 +1,12 @@ +export interface IEventPublisher { + publishResult( + transactionId: string, + status: string, + reasons: string[], + matchedRules: string[], + correlationId: string, + causationId: string, + ): Promise; +} + +export const EVENT_PUBLISHER = 'EVENT_PUBLISHER'; diff --git a/apps/anti-fraud-service/src/domain/repositories/fraud-rule.repository.interface.ts b/apps/anti-fraud-service/src/domain/repositories/fraud-rule.repository.interface.ts new file mode 100644 index 0000000000..2c62fb9846 --- /dev/null +++ b/apps/anti-fraud-service/src/domain/repositories/fraud-rule.repository.interface.ts @@ -0,0 +1,9 @@ +import type { FraudRule } from '../entities/fraud-rule.entity'; +import type { FraudRuleExecution } from '../types/fraud-rule-execution.type'; + +export interface IFraudRuleRepository { + findActiveRules(): Promise; + logExecution(execution: FraudRuleExecution): Promise; +} + +export const FRAUD_RULE_REPOSITORY = 'FRAUD_RULE_REPOSITORY'; diff --git a/apps/anti-fraud-service/src/domain/services/rules-engine.service.spec.ts b/apps/anti-fraud-service/src/domain/services/rules-engine.service.spec.ts new file mode 100644 index 0000000000..5b75904d22 --- /dev/null +++ b/apps/anti-fraud-service/src/domain/services/rules-engine.service.spec.ts @@ -0,0 +1,724 @@ +import { FraudAction, type FraudRuleEvaluationResult, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { FraudRule } from '../entities/fraud-rule.entity'; +import { RulesEngineService } from './rules-engine.service'; + +describe('RulesEngineService', () => { + let service: RulesEngineService; + let mockRepository: any; + let mockRules: FraudRule[]; + + beforeEach(() => { + mockRules = [ + new FraudRule( + '1', + 'High Value Rule', + 'Flag transactions over $1000', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'gt', value: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ), + new FraudRule( + '2', + 'Reject Rule', + 'Reject transactions over $5000', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'gt', value: 5000 }, + FraudAction.REJECT, + 2, + true, + new Date(), + new Date(), + ), + ]; + + mockRepository = { + findActiveRules: jest.fn().mockResolvedValue(mockRules), + logExecution: jest.fn().mockResolvedValue(undefined), + }; + + service = new RulesEngineService(mockRepository); + }); + + describe('evaluateTransaction', () => { + it('should return evaluation results for all active rules', async () => { + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 100, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results).toHaveLength(2); + expect(results[0]).toHaveProperty('ruleId'); + expect(results[0]).toHaveProperty('ruleName'); + expect(results[0]).toHaveProperty('matched'); + expect(results[0]).toHaveProperty('action'); + }); + + it('should match rules when conditions are met', async () => { + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 1500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + const matchedResult = results.find((r) => r.ruleId === '1'); + expect(matchedResult?.matched).toBe(true); + expect(matchedResult?.reason).toContain('1500 gt 1000'); + }); + }); + + describe('determineFinalAction', () => { + it('should return APPROVE when no rules match', () => { + const ruleResults: FraudRuleEvaluationResult[] = []; + + const result = service.determineFinalAction(ruleResults); + + expect(result.action).toBe(FraudAction.APPROVE); + expect(result.matchedRules).toHaveLength(0); + expect(result.reasons).toEqual(['No fraud rules matched']); + }); + + it('should return REJECT when highest priority rule rejects', () => { + const ruleResults: FraudRuleEvaluationResult[] = [ + { + ruleId: '1', + ruleName: 'High Value Rule', + action: FraudAction.FLAG, + matched: true, + reason: 'Amount exceeds threshold', + }, + { + ruleId: '2', + ruleName: 'Reject Rule', + action: FraudAction.REJECT, + matched: true, + reason: 'Amount too high', + }, + ]; + + const result = service.determineFinalAction(ruleResults); + + expect(result.action).toBe(FraudAction.REJECT); + expect(result.matchedRules).toHaveLength(2); + expect(result.reasons).toContain('Reject Rule: Amount too high'); + }); + + it('should return FLAG when highest priority rule flags', () => { + const ruleResults: FraudRuleEvaluationResult[] = [ + { + ruleId: '1', + ruleName: 'High Value Rule', + action: FraudAction.FLAG, + matched: true, + reason: 'Amount exceeds threshold', + }, + ]; + + const result = service.determineFinalAction(ruleResults); + + expect(result.action).toBe(FraudAction.FLAG); + expect(result.matchedRules).toHaveLength(1); + expect(result.reasons).toContain('High Value Rule: Amount exceeds threshold'); + }); + + it('should return REVIEW when REVIEW has higher priority than FLAG', () => { + const ruleResults: FraudRuleEvaluationResult[] = [ + { + ruleId: '1', + ruleName: 'Flag Rule', + action: FraudAction.FLAG, + matched: true, + reason: 'Flagged for review', + }, + { + ruleId: '2', + ruleName: 'Review Rule', + action: FraudAction.REVIEW, + matched: true, + reason: 'Requires manual review', + }, + ]; + + const result = service.determineFinalAction(ruleResults); + + expect(result.action).toBe(FraudAction.REVIEW); + expect(result.matchedRules).toHaveLength(2); + }); + + it('should filter out non-matched rules', () => { + const ruleResults: FraudRuleEvaluationResult[] = [ + { + ruleId: '1', + ruleName: 'Matched Rule', + action: FraudAction.FLAG, + matched: true, + reason: 'Matched', + }, + { + ruleId: '2', + ruleName: 'Non-matched Rule', + action: FraudAction.REJECT, + matched: false, + }, + ]; + + const result = service.determineFinalAction(ruleResults); + + expect(result.action).toBe(FraudAction.FLAG); + expect(result.matchedRules).toHaveLength(1); + expect(result.matchedRules[0].ruleId).toBe('1'); + }); + }); + + describe('AMOUNT_THRESHOLD operators', () => { + it('should evaluate gte operator correctly', async () => { + const gteRule = new FraudRule( + 'gte-1', + 'GTE Rule', + 'Greater than or equal', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'gte', value: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([gteRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 1000, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + + it('should evaluate lt operator correctly', async () => { + const ltRule = new FraudRule( + 'lt-1', + 'LT Rule', + 'Less than', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'lt', value: 100 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([ltRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 50, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + + it('should evaluate lte operator correctly', async () => { + const lteRule = new FraudRule( + 'lte-1', + 'LTE Rule', + 'Less than or equal', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'lte', value: 100 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([lteRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 100, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + + it('should evaluate eq operator correctly', async () => { + const eqRule = new FraudRule( + 'eq-1', + 'EQ Rule', + 'Equals', + FraudRuleType.AMOUNT_THRESHOLD, + { operator: 'eq', value: 999.99 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([eqRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 999.99, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + }); + + describe('ACCOUNT_BLACKLIST rule type', () => { + it('should match when debit account is blacklisted', async () => { + const blacklistRule = new FraudRule( + 'blacklist-1', + 'Account Blacklist', + 'Reject blacklisted accounts', + FraudRuleType.ACCOUNT_BLACKLIST, + { accounts: ['blacklisted-account-123'] }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([blacklistRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'blacklisted-account-123', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + expect(results[0].reason).toBe('Account is in blacklist'); + }); + + it('should match when credit account is blacklisted', async () => { + const blacklistRule = new FraudRule( + 'blacklist-1', + 'Account Blacklist', + 'Reject blacklisted accounts', + FraudRuleType.ACCOUNT_BLACKLIST, + { accounts: ['blacklisted-credit-456'] }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([blacklistRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'blacklisted-credit-456', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + + it('should not match when accounts are not blacklisted', async () => { + const blacklistRule = new FraudRule( + 'blacklist-1', + 'Account Blacklist', + 'Reject blacklisted accounts', + FraudRuleType.ACCOUNT_BLACKLIST, + { accounts: ['blacklisted-account-999'] }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([blacklistRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + }); + }); + + describe('TRANSFER_TYPE_LIMIT rule type', () => { + it('should match when transfer type matches and exceeds limit', async () => { + const typeLimitRule = new FraudRule( + 'type-limit-1', + 'Transfer Type Limit', + 'Limit transfers', + FraudRuleType.TRANSFER_TYPE_LIMIT, + { transferTypeId: 1, maxAmount: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([typeLimitRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 1500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + expect(results[0].reason).toBe('Transfer type 1 exceeds limit of 1000'); + }); + + it('should not match when transfer type is different', async () => { + const typeLimitRule = new FraudRule( + 'type-limit-1', + 'Transfer Type Limit', + 'Limit transfers', + FraudRuleType.TRANSFER_TYPE_LIMIT, + { transferTypeId: 2, maxAmount: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([typeLimitRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 1500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + }); + + it('should not match when amount is within limit', async () => { + const typeLimitRule = new FraudRule( + 'type-limit-1', + 'Transfer Type Limit', + 'Limit transfers', + FraudRuleType.TRANSFER_TYPE_LIMIT, + { transferTypeId: 1, maxAmount: 1000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([typeLimitRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + }); + }); + + describe('TIME_BASED rule type', () => { + it('should match when transaction is outside allowed hours', async () => { + const timeRule = new FraudRule( + 'time-1', + 'Business Hours Only', + 'Only allow transactions during business hours', + FraudRuleType.TIME_BASED, + { + allowedHours: { start: 9, end: 17 }, + allowedDays: [1, 2, 3, 4, 5], + }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([timeRule]); + + const testDate = new Date('2024-01-08T00:00:00'); + testDate.setHours(22, 0, 0, 0); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: testDate, + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + expect(results[0].reason).toBe('Transaction outside allowed time window'); + }); + + it('should match when transaction is on disallowed day', async () => { + const timeRule = new FraudRule( + 'time-1', + 'Weekdays Only', + 'Only allow transactions on weekdays', + FraudRuleType.TIME_BASED, + { + allowedHours: { start: 0, end: 23 }, + allowedDays: [1, 2, 3, 4, 5], + }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([timeRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date('2024-01-07T12:00:00Z'), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(true); + }); + + it('should not match when transaction is within allowed time window', async () => { + const timeRule = new FraudRule( + 'time-1', + 'Business Hours', + 'Allow during business hours', + FraudRuleType.TIME_BASED, + { + allowedHours: { start: 9, end: 17 }, + allowedDays: [1, 2, 3, 4, 5], + }, + FraudAction.REJECT, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([timeRule]); + + const testDate = new Date('2024-01-08T00:00:00'); + testDate.setHours(12, 0, 0, 0); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: testDate, + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + }); + }); + + describe('DAILY_LIMIT rule type', () => { + it('should return not matched with pending implementation message', async () => { + const dailyLimitRule = new FraudRule( + 'daily-1', + 'Daily Limit', + 'Limit daily transactions', + FraudRuleType.DAILY_LIMIT, + { maxCount: 10, maxAmount: 5000 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([dailyLimitRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + expect(results[0].reason).toBe('Daily limit check - implementation pending'); + }); + }); + + describe('VELOCITY_CHECK rule type', () => { + it('should return not matched with pending implementation message', async () => { + const velocityRule = new FraudRule( + 'velocity-1', + 'Velocity Check', + 'Check transaction velocity', + FraudRuleType.VELOCITY_CHECK, + { maxTransactions: 5, windowMinutes: 60 }, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([velocityRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + expect(results[0].reason).toBe('Velocity check - implementation pending'); + }); + }); + + describe('error handling', () => { + it('should handle unknown rule types gracefully', async () => { + const unknownRule = new FraudRule( + 'unknown-1', + 'Unknown Rule', + 'Test unknown type', + 'UNKNOWN_TYPE' as any, + {}, + FraudAction.FLAG, + 1, + true, + new Date(), + new Date(), + ); + + mockRepository.findActiveRules.mockResolvedValue([unknownRule]); + + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 500, + createdAt: new Date(), + }; + + const results = await service.evaluateTransaction(context); + + expect(results[0].matched).toBe(false); + expect(results[0].reason).toBe('Unknown rule type: UNKNOWN_TYPE'); + }); + + it('should log rule executions', async () => { + const context = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'acc-debit', + accountExternalIdCredit: 'acc-credit', + transferTypeId: 1, + value: 1500, + createdAt: new Date(), + }; + + await service.evaluateTransaction(context); + + expect(mockRepository.logExecution).toHaveBeenCalledTimes(2); + expect(mockRepository.logExecution).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId: expect.any(String), + transactionExternalId: 'txn-123', + matched: expect.any(Boolean), + action: expect.any(String), + }), + ); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/domain/services/rules-engine.service.ts b/apps/anti-fraud-service/src/domain/services/rules-engine.service.ts new file mode 100644 index 0000000000..1b50caae66 --- /dev/null +++ b/apps/anti-fraud-service/src/domain/services/rules-engine.service.ts @@ -0,0 +1,274 @@ +import { + type AccountBlacklistCondition, + type AmountThresholdCondition, + type DailyLimitCondition, + FraudAction, + type FraudEvaluationContext, + type FraudRuleEvaluationResult, + FraudRuleType, + type TimeBasedCondition, + type TransferTypeLimitCondition, + type VelocityCheckCondition, +} from '@app/common/types/fraud-rules.types'; +import { Inject, Injectable } from '@nestjs/common'; +import type { FraudRule } from '../entities/fraud-rule.entity'; +import { FRAUD_RULE_REPOSITORY, type IFraudRuleRepository } from '../repositories/fraud-rule.repository.interface'; + +@Injectable() +export class RulesEngineService { + constructor( + @Inject(FRAUD_RULE_REPOSITORY) + private readonly fraudRuleRepository: IFraudRuleRepository, + ) {} + + async evaluateTransaction(context: FraudEvaluationContext): Promise { + const activeRules = await this.fraudRuleRepository.findActiveRules(); + + const results: FraudRuleEvaluationResult[] = []; + + for (const rule of activeRules) { + const result = await this.evaluateRule(rule, context); + results.push(result); + + await this.logRuleExecution(rule.id, context.transactionExternalId, result); + } + + return results; + } + + private async evaluateRule(rule: FraudRule, context: FraudEvaluationContext): Promise { + const baseResult: FraudRuleEvaluationResult = { + ruleId: rule.id, + ruleName: rule.name, + matched: false, + action: rule.action as FraudAction, + }; + + try { + switch (rule.ruleType as FraudRuleType) { + case FraudRuleType.AMOUNT_THRESHOLD: + return this.evaluateAmountThreshold(rule, context, baseResult); + + case FraudRuleType.DAILY_LIMIT: + return await this.evaluateDailyLimit(rule, context, baseResult); + + case FraudRuleType.VELOCITY_CHECK: + return await this.evaluateVelocityCheck(rule, context, baseResult); + + case FraudRuleType.ACCOUNT_BLACKLIST: + return this.evaluateAccountBlacklist(rule, context, baseResult); + + case FraudRuleType.TRANSFER_TYPE_LIMIT: + return this.evaluateTransferTypeLimit(rule, context, baseResult); + + case FraudRuleType.TIME_BASED: + return this.evaluateTimeBased(rule, context, baseResult); + + default: + return { + ...baseResult, + reason: `Unknown rule type: ${rule.ruleType}`, + }; + } + } catch (error) { + console.error(`Error evaluating rule ${rule.name}:`, error); + return { + ...baseResult, + reason: `Evaluation error: ${error.message}`, + }; + } + } + + private evaluateAmountThreshold( + rule: FraudRule, + context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): FraudRuleEvaluationResult { + const condition = rule.condition as AmountThresholdCondition; + let matched = false; + + switch (condition.operator) { + case 'gt': + matched = context.value > condition.value; + break; + case 'gte': + matched = context.value >= condition.value; + break; + case 'lt': + matched = context.value < condition.value; + break; + case 'lte': + matched = context.value <= condition.value; + break; + case 'eq': + matched = context.value === condition.value; + break; + } + + return { + ...baseResult, + matched, + reason: matched + ? `Transaction amount ${context.value} ${condition.operator} ${condition.value}` + : undefined, + details: { value: context.value, threshold: condition.value }, + }; + } + + private async evaluateDailyLimit( + rule: FraudRule, + _context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): Promise { + const _condition = rule.condition as DailyLimitCondition; + + return { + ...baseResult, + matched: false, + reason: 'Daily limit check - implementation pending', + }; + } + + private async evaluateVelocityCheck( + rule: FraudRule, + _context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): Promise { + const _condition = rule.condition as VelocityCheckCondition; + + return { + ...baseResult, + matched: false, + reason: 'Velocity check - implementation pending', + }; + } + + private evaluateAccountBlacklist( + rule: FraudRule, + context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): FraudRuleEvaluationResult { + const condition = rule.condition as AccountBlacklistCondition; + + const isBlacklisted = + condition.accounts.includes(context.accountExternalIdDebit) || + condition.accounts.includes(context.accountExternalIdCredit); + + return { + ...baseResult, + matched: isBlacklisted, + reason: isBlacklisted ? 'Account is in blacklist' : undefined, + details: { + debitAccount: context.accountExternalIdDebit, + creditAccount: context.accountExternalIdCredit, + }, + }; + } + + private evaluateTransferTypeLimit( + rule: FraudRule, + context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): FraudRuleEvaluationResult { + const condition = rule.condition as TransferTypeLimitCondition; + + const matched = context.transferTypeId === condition.transferTypeId && context.value > condition.maxAmount; + + return { + ...baseResult, + matched, + reason: matched + ? `Transfer type ${context.transferTypeId} exceeds limit of ${condition.maxAmount}` + : undefined, + details: { + transferTypeId: context.transferTypeId, + value: context.value, + maxAmount: condition.maxAmount, + }, + }; + } + + private evaluateTimeBased( + rule: FraudRule, + context: FraudEvaluationContext, + baseResult: FraudRuleEvaluationResult, + ): FraudRuleEvaluationResult { + const condition = rule.condition as TimeBasedCondition; + const date = new Date(context.createdAt); + const hour = date.getHours(); + const day = date.getDay(); + + const hourAllowed = hour >= condition.allowedHours.start && hour <= condition.allowedHours.end; + const dayAllowed = condition.allowedDays.includes(day); + + const matched = !hourAllowed || !dayAllowed; + + return { + ...baseResult, + matched, + reason: matched ? 'Transaction outside allowed time window' : undefined, + details: { + hour, + day, + allowedHours: condition.allowedHours, + allowedDays: condition.allowedDays, + }, + }; + } + + private async logRuleExecution( + ruleId: string, + transactionId: string, + result: FraudRuleEvaluationResult, + ): Promise { + await this.fraudRuleRepository.logExecution({ + ruleId, + transactionExternalId: transactionId, + matched: result.matched, + action: result.action, + details: result.details || {}, + }); + } + + determineFinalAction(results: FraudRuleEvaluationResult[]): { + action: FraudAction; + matchedRules: FraudRuleEvaluationResult[]; + reasons: string[]; + } { + const matchedRules = results.filter((r) => r.matched); + + if (matchedRules.length === 0) { + return { + action: FraudAction.APPROVE, + matchedRules: [], + reasons: ['No fraud rules matched'], + }; + } + + const actionPriority = { + [FraudAction.REJECT]: 4, + [FraudAction.REVIEW]: 3, + [FraudAction.FLAG]: 2, + [FraudAction.APPROVE]: 1, + }; + + let finalAction = FraudAction.APPROVE; + let maxPriority = 0; + + for (const rule of matchedRules) { + const priority = actionPriority[rule.action]; + if (priority > maxPriority) { + maxPriority = priority; + finalAction = rule.action; + } + } + + const reasons = matchedRules.filter((r) => r.reason).map((r) => `${r.ruleName}: ${r.reason}`); + + return { + action: finalAction, + matchedRules, + reasons, + }; + } +} diff --git a/apps/anti-fraud-service/src/domain/types/fraud-rule-execution.type.ts b/apps/anti-fraud-service/src/domain/types/fraud-rule-execution.type.ts new file mode 100644 index 0000000000..76f87f66ec --- /dev/null +++ b/apps/anti-fraud-service/src/domain/types/fraud-rule-execution.type.ts @@ -0,0 +1,9 @@ +import type { FraudAction } from '@app/common/types/fraud-rules.types'; + +export interface FraudRuleExecution { + ruleId: string; + transactionExternalId: string; + matched: boolean; + action: FraudAction; + details?: Record; +} diff --git a/apps/anti-fraud-service/src/infrastructure/database/fraud-rules.schema.ts b/apps/anti-fraud-service/src/infrastructure/database/fraud-rules.schema.ts new file mode 100644 index 0000000000..26a81aec37 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/database/fraud-rules.schema.ts @@ -0,0 +1,31 @@ +import { boolean, integer, jsonb, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; + +export const fraudRules = pgTable('fraud_rules', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + ruleType: varchar('rule_type', { length: 50 }).notNull(), + condition: jsonb('condition').notNull(), + action: varchar('action', { length: 20 }).notNull().default('REJECT'), + priority: integer('priority').notNull().default(100), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}); + +export const fraudRuleExecutions = pgTable('fraud_rule_executions', { + id: uuid('id').primaryKey().defaultRandom(), + ruleId: uuid('rule_id') + .notNull() + .references(() => fraudRules.id), + transactionExternalId: uuid('transaction_external_id').notNull(), + matched: boolean('matched').notNull(), + action: varchar('action', { length: 20 }).notNull(), + details: jsonb('details'), + executedAt: timestamp('executed_at').notNull().defaultNow(), +}); + +export type FraudRule = typeof fraudRules.$inferSelect; +export type NewFraudRule = typeof fraudRules.$inferInsert; +export type FraudRuleExecution = typeof fraudRuleExecutions.$inferSelect; +export type NewFraudRuleExecution = typeof fraudRuleExecutions.$inferInsert; diff --git a/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.spec.ts b/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.spec.ts new file mode 100644 index 0000000000..d424b0090b --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.spec.ts @@ -0,0 +1,290 @@ +import { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { FraudRule } from '../../domain/entities/fraud-rule.entity'; +import type { FraudRule as FraudRuleSchema } from '../database/fraud-rules.schema'; +import { FraudRuleMapper } from './fraud-rule.mapper'; + +describe('FraudRuleMapper', () => { + describe('toDomain', () => { + it('should map schema to domain entity with all fields', () => { + const schema: FraudRuleSchema = { + id: 'rule-123', + name: 'High Value Rule', + description: 'Flag high value transactions', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 1000 }, + action: FraudAction.FLAG, + priority: 1, + isActive: true, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result).toBeInstanceOf(FraudRule); + expect(result.id).toBe('rule-123'); + expect(result.name).toBe('High Value Rule'); + expect(result.description).toBe('Flag high value transactions'); + expect(result.ruleType).toBe(FraudRuleType.AMOUNT_THRESHOLD); + expect(result.condition).toEqual({ operator: 'gt', value: 1000 }); + expect(result.action).toBe(FraudAction.FLAG); + expect(result.priority).toBe(1); + expect(result.isActive).toBe(true); + expect(result.createdAt).toEqual(new Date('2024-01-01T00:00:00Z')); + expect(result.updatedAt).toEqual(new Date('2024-01-02T00:00:00Z')); + }); + + it('should map ACCOUNT_BLACKLIST rule type', () => { + const schema: FraudRuleSchema = { + id: 'rule-blacklist', + name: 'Blacklist Rule', + description: 'Block blacklisted accounts', + ruleType: FraudRuleType.ACCOUNT_BLACKLIST, + condition: { accounts: ['acc-123', 'acc-456'] }, + action: FraudAction.REJECT, + priority: 2, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.ruleType).toBe(FraudRuleType.ACCOUNT_BLACKLIST); + expect(result.condition).toEqual({ accounts: ['acc-123', 'acc-456'] }); + }); + + it('should map TRANSFER_TYPE_LIMIT rule type', () => { + const schema: FraudRuleSchema = { + id: 'rule-transfer-limit', + name: 'Transfer Limit', + description: 'Limit transfer amounts', + ruleType: FraudRuleType.TRANSFER_TYPE_LIMIT, + condition: { transferTypeId: 1, maxAmount: 5000 }, + action: FraudAction.FLAG, + priority: 3, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.ruleType).toBe(FraudRuleType.TRANSFER_TYPE_LIMIT); + expect(result.condition).toEqual({ transferTypeId: 1, maxAmount: 5000 }); + }); + + it('should map TIME_BASED rule type', () => { + const schema: FraudRuleSchema = { + id: 'rule-time', + name: 'Business Hours', + description: 'Only allow during business hours', + ruleType: FraudRuleType.TIME_BASED, + condition: { + allowedHours: { start: 9, end: 17 }, + allowedDays: [1, 2, 3, 4, 5], + }, + action: FraudAction.REJECT, + priority: 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.ruleType).toBe(FraudRuleType.TIME_BASED); + expect(result.condition).toEqual({ + allowedHours: { start: 9, end: 17 }, + allowedDays: [1, 2, 3, 4, 5], + }); + }); + + it('should map DAILY_LIMIT rule type', () => { + const schema: FraudRuleSchema = { + id: 'rule-daily', + name: 'Daily Limit', + description: 'Limit daily transactions', + ruleType: FraudRuleType.DAILY_LIMIT, + condition: { maxCount: 10, maxAmount: 10000 }, + action: FraudAction.REVIEW, + priority: 4, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.ruleType).toBe(FraudRuleType.DAILY_LIMIT); + expect(result.condition).toEqual({ maxCount: 10, maxAmount: 10000 }); + }); + + it('should map VELOCITY_CHECK rule type', () => { + const schema: FraudRuleSchema = { + id: 'rule-velocity', + name: 'Velocity Check', + description: 'Check transaction velocity', + ruleType: FraudRuleType.VELOCITY_CHECK, + condition: { maxTransactions: 5, windowMinutes: 60 }, + action: FraudAction.FLAG, + priority: 5, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.ruleType).toBe(FraudRuleType.VELOCITY_CHECK); + expect(result.condition).toEqual({ maxTransactions: 5, windowMinutes: 60 }); + }); + + it('should map REJECT action', () => { + const schema: FraudRuleSchema = { + id: 'rule-reject', + name: 'Reject Rule', + description: 'Reject transaction', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 10000 }, + action: FraudAction.REJECT, + priority: 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.action).toBe(FraudAction.REJECT); + }); + + it('should map REVIEW action', () => { + const schema: FraudRuleSchema = { + id: 'rule-review', + name: 'Review Rule', + description: 'Requires review', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 5000 }, + action: FraudAction.REVIEW, + priority: 2, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.action).toBe(FraudAction.REVIEW); + }); + + it('should map APPROVE action', () => { + const schema: FraudRuleSchema = { + id: 'rule-approve', + name: 'Approve Rule', + description: 'Auto approve', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'lt', value: 100 }, + action: FraudAction.APPROVE, + priority: 10, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.action).toBe(FraudAction.APPROVE); + }); + + it('should map inactive rules', () => { + const schema: FraudRuleSchema = { + id: 'rule-inactive', + name: 'Inactive Rule', + description: 'This rule is inactive', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 1000 }, + action: FraudAction.FLAG, + priority: 1, + isActive: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.isActive).toBe(false); + }); + + it('should preserve priority values', () => { + const schema: FraudRuleSchema = { + id: 'rule-priority', + name: 'Priority Rule', + description: 'Test priority', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 1000 }, + action: FraudAction.FLAG, + priority: 99, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.priority).toBe(99); + }); + + it('should handle complex JSONB conditions', () => { + const complexCondition = { + operator: 'gte', + value: 1000, + currency: 'USD', + metadata: { + threshold: 'high', + category: 'suspicious', + }, + }; + + const schema: FraudRuleSchema = { + id: 'rule-complex', + name: 'Complex Rule', + description: 'Complex condition', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: complexCondition, + action: FraudAction.FLAG, + priority: 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.condition).toEqual(complexCondition); + }); + + it('should preserve timestamps', () => { + const createdAt = new Date('2024-01-01T10:00:00Z'); + const updatedAt = new Date('2024-01-15T15:30:00Z'); + + const schema: FraudRuleSchema = { + id: 'rule-timestamps', + name: 'Timestamp Rule', + description: 'Test timestamps', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 1000 }, + action: FraudAction.FLAG, + priority: 1, + isActive: true, + createdAt, + updatedAt, + }; + + const result = FraudRuleMapper.toDomain(schema); + + expect(result.createdAt).toEqual(createdAt); + expect(result.updatedAt).toEqual(updatedAt); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.ts b/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.ts new file mode 100644 index 0000000000..c86e4ab163 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/mappers/fraud-rule.mapper.ts @@ -0,0 +1,20 @@ +import type { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { FraudRule } from '../../domain/entities/fraud-rule.entity'; +import type { FraudRule as FraudRuleSchema } from '../../infrastructure/database/fraud-rules.schema'; + +export class FraudRuleMapper { + static toDomain(schema: FraudRuleSchema): FraudRule { + return new FraudRule( + schema.id, + schema.name, + schema.description, + schema.ruleType as FraudRuleType, + schema.condition, + schema.action as FraudAction, + schema.priority, + schema.isActive, + schema.createdAt, + schema.updatedAt, + ); + } +} diff --git a/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.spec.ts b/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.spec.ts new file mode 100644 index 0000000000..ac83049780 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.spec.ts @@ -0,0 +1,467 @@ +import type { KafkaProducerService } from '@app/common'; +import { KafkaTopics, TransactionStatus } from '@app/common'; +import { KafkaEventPublisher } from './kafka-event-publisher'; + +describe('KafkaEventPublisher', () => { + let publisher: KafkaEventPublisher; + let mockKafkaProducer: jest.Mocked; + + beforeEach(() => { + mockKafkaProducer = { + sendMessage: jest.fn().mockResolvedValue(undefined), + } as any; + + publisher = new KafkaEventPublisher(mockKafkaProducer); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('publishResult', () => { + describe('when status is approved', () => { + it('should publish to TRANSACTION_APPROVED topic', async () => { + await publisher.publishResult( + 'txn-123', + TransactionStatus.APPROVED, + ['No fraud detected'], + [], + 'correlation-123', + 'causation-456', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_APPROVED, + 'txn-123', + expect.any(Object), + ); + }); + + it('should include transaction details in event', async () => { + await publisher.publishResult( + 'txn-approved', + TransactionStatus.APPROVED, + ['All checks passed'], + [], + 'correlation-approved', + 'causation-approved', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + 'txn-approved', + expect.objectContaining({ + transactionExternalId: 'txn-approved', + transactionStatus: TransactionStatus.APPROVED, + }), + ); + }); + + it('should include reason from reasons array', async () => { + await publisher.publishResult( + 'txn-reason', + TransactionStatus.APPROVED, + ['Reason 1', 'Reason 2', 'Reason 3'], + [], + 'correlation-reason', + 'causation-reason', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + reason: 'Reason 1; Reason 2; Reason 3', + }), + ); + }); + + it('should use default reason when reasons array is empty', async () => { + await publisher.publishResult( + 'txn-default', + TransactionStatus.APPROVED, + [], + [], + 'correlation-default', + 'causation-default', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + reason: 'Transaction validated successfully', + }), + ); + }); + + it('should include matched rules array', async () => { + await publisher.publishResult( + 'txn-rules', + TransactionStatus.APPROVED, + ['No rules matched'], + [], + 'correlation-rules', + 'causation-rules', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + matchedRules: [], + }), + ); + }); + + it('should include validatedAt timestamp', async () => { + const beforeCall = new Date().toISOString(); + + await publisher.publishResult( + 'txn-timestamp', + TransactionStatus.APPROVED, + [], + [], + 'correlation-timestamp', + 'causation-timestamp', + ); + + const afterCall = new Date().toISOString(); + + const callArgs = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + expect(callArgs.validatedAt).toBeDefined(); + expect(callArgs.validatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(callArgs.validatedAt >= beforeCall).toBe(true); + expect(callArgs.validatedAt <= afterCall).toBe(true); + }); + + it('should include metadata with correlationId and causationId', async () => { + await publisher.publishResult( + 'txn-metadata', + TransactionStatus.APPROVED, + [], + [], + 'correlation-metadata', + 'causation-metadata', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'correlation-metadata', + causationId: 'causation-metadata', + }), + }), + ); + }); + + it('should include metadata with service name and version', async () => { + await publisher.publishResult( + 'txn-service', + TransactionStatus.APPROVED, + [], + [], + 'correlation-service', + 'causation-service', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + service: 'AntiFraudService', + version: '1.0.0', + }), + }), + ); + }); + + it('should include metadata timestamp', async () => { + await publisher.publishResult( + 'txn-meta-timestamp', + TransactionStatus.APPROVED, + [], + [], + 'correlation-meta', + 'causation-meta', + ); + + const callArgs = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + expect(callArgs.metadata.timestamp).toBeDefined(); + expect(typeof callArgs.metadata.timestamp).toBe('string'); + }); + }); + + describe('when status is rejected', () => { + it('should publish to TRANSACTION_REJECTED topic', async () => { + await publisher.publishResult( + 'txn-rejected', + TransactionStatus.REJECTED, + ['Fraud detected'], + ['High Value Rule'], + 'correlation-rejected', + 'causation-rejected', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_REJECTED, + 'txn-rejected', + expect.any(Object), + ); + }); + + it('should include rejection reason', async () => { + await publisher.publishResult( + 'txn-fraud', + TransactionStatus.REJECTED, + ['Amount exceeds threshold', 'Suspicious pattern detected'], + ['Rule 1', 'Rule 2'], + 'correlation-fraud', + 'causation-fraud', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + reason: 'Amount exceeds threshold; Suspicious pattern detected', + transactionStatus: TransactionStatus.REJECTED, + }), + ); + }); + + it('should include matched rule names', async () => { + await publisher.publishResult( + 'txn-matched', + TransactionStatus.REJECTED, + ['Blacklisted account'], + ['Account Blacklist Rule', 'Velocity Check'], + 'correlation-matched', + 'causation-matched', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + matchedRules: ['Account Blacklist Rule', 'Velocity Check'], + }), + ); + }); + + it('should include transaction external ID', async () => { + await publisher.publishResult( + 'txn-external-123', + TransactionStatus.REJECTED, + ['Fraud'], + ['Rule'], + 'correlation-ext', + 'causation-ext', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + 'txn-external-123', + expect.objectContaining({ + transactionExternalId: 'txn-external-123', + }), + ); + }); + + it('should include metadata with correct correlation chain', async () => { + await publisher.publishResult( + 'txn-chain', + TransactionStatus.REJECTED, + ['Rejected'], + [], + 'original-correlation-id', + 'original-request-id', + ); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'original-correlation-id', + causationId: 'original-request-id', + }), + }), + ); + }); + }); + + describe('topic routing', () => { + it('should route to approved topic for APPROVED status', async () => { + await publisher.publishResult( + 'txn-route-approved', + TransactionStatus.APPROVED, + [], + [], + 'correlation', + 'causation', + ); + + const topic = mockKafkaProducer.sendMessage.mock.calls[0][0]; + expect(topic).toBe(KafkaTopics.TRANSACTION_APPROVED); + }); + + it('should route to rejected topic for REJECTED status', async () => { + await publisher.publishResult( + 'txn-route-rejected', + TransactionStatus.REJECTED, + [], + [], + 'correlation', + 'causation', + ); + + const topic = mockKafkaProducer.sendMessage.mock.calls[0][0]; + expect(topic).toBe(KafkaTopics.TRANSACTION_REJECTED); + }); + + it('should use transaction ID as message key', async () => { + await publisher.publishResult( + 'txn-key-123', + TransactionStatus.APPROVED, + [], + [], + 'correlation', + 'causation', + ); + + const messageKey = mockKafkaProducer.sendMessage.mock.calls[0][1]; + expect(messageKey).toBe('txn-key-123'); + }); + }); + + describe('reason formatting', () => { + it('should join multiple reasons with semicolon and space', async () => { + await publisher.publishResult( + 'txn-join', + TransactionStatus.REJECTED, + ['Reason A', 'Reason B', 'Reason C', 'Reason D'], + [], + 'correlation', + 'causation', + ); + + const event = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + expect(event.reason).toBe('Reason A; Reason B; Reason C; Reason D'); + }); + + it('should handle single reason', async () => { + await publisher.publishResult( + 'txn-single', + TransactionStatus.APPROVED, + ['Single reason'], + [], + 'correlation', + 'causation', + ); + + const event = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + expect(event.reason).toBe('Single reason'); + }); + + it('should use default message for empty reasons array', async () => { + await publisher.publishResult( + 'txn-empty', + TransactionStatus.APPROVED, + [], + [], + 'correlation', + 'causation', + ); + + const event = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + expect(event.reason).toBe('Transaction validated successfully'); + }); + }); + + describe('error handling', () => { + it('should propagate Kafka producer errors', async () => { + mockKafkaProducer.sendMessage.mockRejectedValue(new Error('Kafka connection error')); + + await expect( + publisher.publishResult( + 'txn-error', + TransactionStatus.APPROVED, + [], + [], + 'correlation', + 'causation', + ), + ).rejects.toThrow('Kafka connection error'); + }); + + it('should not catch producer errors', async () => { + mockKafkaProducer.sendMessage.mockRejectedValue(new Error('Network timeout')); + + await expect( + publisher.publishResult( + 'txn-timeout', + TransactionStatus.REJECTED, + ['Fraud'], + [], + 'correlation', + 'causation', + ), + ).rejects.toThrow('Network timeout'); + }); + }); + + describe('event structure completeness', () => { + it('should include all required fields in approved event', async () => { + await publisher.publishResult( + 'txn-complete', + TransactionStatus.APPROVED, + ['Complete check'], + ['Rule A', 'Rule B'], + 'correlation-complete', + 'causation-complete', + ); + + const event = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + + expect(event).toHaveProperty('transactionExternalId'); + expect(event).toHaveProperty('transactionStatus'); + expect(event).toHaveProperty('reason'); + expect(event).toHaveProperty('matchedRules'); + expect(event).toHaveProperty('validatedAt'); + expect(event).toHaveProperty('metadata'); + expect(event.metadata).toHaveProperty('correlationId'); + expect(event.metadata).toHaveProperty('causationId'); + expect(event.metadata).toHaveProperty('timestamp'); + expect(event.metadata).toHaveProperty('service'); + expect(event.metadata).toHaveProperty('version'); + }); + + it('should include all required fields in rejected event', async () => { + await publisher.publishResult( + 'txn-complete-reject', + TransactionStatus.REJECTED, + ['Fraud detected'], + ['Blacklist'], + 'correlation-reject-complete', + 'causation-reject-complete', + ); + + const event = mockKafkaProducer.sendMessage.mock.calls[0][2] as any; + + expect(event.transactionExternalId).toBe('txn-complete-reject'); + expect(event.transactionStatus).toBe(TransactionStatus.REJECTED); + expect(event.reason).toBe('Fraud detected'); + expect(event.matchedRules).toEqual(['Blacklist']); + expect(event.validatedAt).toBeDefined(); + expect(event.metadata.correlationId).toBe('correlation-reject-complete'); + expect(event.metadata.causationId).toBe('causation-reject-complete'); + expect(event.metadata.service).toBe('AntiFraudService'); + expect(event.metadata.version).toBe('1.0.0'); + }); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.ts b/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.ts new file mode 100644 index 0000000000..82f87ccc24 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/messaging/kafka-event-publisher.ts @@ -0,0 +1,37 @@ +import { KafkaProducerService, KafkaTopics, TransactionStatus } from '@app/common'; +import { Injectable } from '@nestjs/common'; +import type { IEventPublisher } from '../../domain/ports/output/event-publisher.interface'; + +@Injectable() +export class KafkaEventPublisher implements IEventPublisher { + constructor(private readonly kafkaProducer: KafkaProducerService) {} + + async publishResult( + transactionId: string, + status: TransactionStatus, + reasons: string[], + matchedRules: string[], + correlationId: string, + causationId: string, + ): Promise { + const topic = + status === TransactionStatus.APPROVED ? KafkaTopics.TRANSACTION_APPROVED : KafkaTopics.TRANSACTION_REJECTED; + + const event = { + transactionExternalId: transactionId, + transactionStatus: status, + reason: reasons.join('; ') || 'Transaction validated successfully', + matchedRules: matchedRules, + validatedAt: new Date().toISOString(), + metadata: { + correlationId, + causationId, + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + version: '1.0.0', + }, + }; + + await this.kafkaProducer.sendMessage(topic, transactionId, event); + } +} diff --git a/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.spec.ts b/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.spec.ts new file mode 100644 index 0000000000..500c28a2b9 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.spec.ts @@ -0,0 +1,612 @@ +import type { KafkaConsumerService, TransactionCreatedEvent } from '@app/common'; +import { KafkaTopics, TransactionStatus } from '@app/common'; +import type { EachMessagePayload } from 'kafkajs'; +import type { IValidateTransactionUseCase } from '../../domain/ports/input/validate-transaction.use-case.interface'; +import { TransactionCreatedConsumer } from './transaction-created.consumer'; + +describe('TransactionCreatedConsumer', () => { + let consumer: TransactionCreatedConsumer; + let mockKafkaConsumer: jest.Mocked; + let mockValidateUseCase: jest.Mocked; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + mockKafkaConsumer = { + registerHandler: jest.fn(), + subscribeAndRun: jest.fn().mockResolvedValue(undefined), + } as any; + + mockValidateUseCase = { + execute: jest.fn().mockResolvedValue(undefined), + } as jest.Mocked; + + consumer = new TransactionCreatedConsumer(mockKafkaConsumer, mockValidateUseCase); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + describe('onModuleInit', () => { + it('should register handler for transaction-created topic', async () => { + await consumer.onModuleInit(); + + expect(mockKafkaConsumer.registerHandler).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_CREATED, + expect.any(Function), + ); + }); + + it('should subscribe to transaction-created topic', async () => { + await consumer.onModuleInit(); + + expect(mockKafkaConsumer.subscribeAndRun).toHaveBeenCalledWith([KafkaTopics.TRANSACTION_CREATED]); + }); + + it('should subscribe after registering handler', async () => { + const callOrder: string[] = []; + + mockKafkaConsumer.registerHandler.mockImplementation(() => { + callOrder.push('registerHandler'); + }); + + mockKafkaConsumer.subscribeAndRun.mockImplementation(async () => { + callOrder.push('subscribeAndRun'); + }); + + await consumer.onModuleInit(); + + expect(callOrder).toEqual(['registerHandler', 'subscribeAndRun']); + }); + }); + + describe('handleTransactionCreated', () => { + const createPayload = (event: TransactionCreatedEvent): EachMessagePayload => { + return { + topic: KafkaTopics.TRANSACTION_CREATED, + partition: 0, + message: { + key: Buffer.from(event.transactionExternalId), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + }; + + it('should parse transaction event and call validate use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + expect(registeredHandler).toBeDefined(); + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-123', + accountExternalIdCredit: 'credit-456', + transferTypeId: 1, + value: 500, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation-123', + causationId: 'causation-456', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + version: '1.0.0', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should pass correct transaction ID to use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-external-id', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 1000, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + 'txn-external-id', + expect.any(Number), + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Date), + expect.any(Object), + ); + }); + + it('should pass transaction value to use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-value', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 2, + value: 750.5, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + 750.5, + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Date), + expect.any(Object), + ); + }); + + it('should pass account IDs to use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-accounts', + accountExternalIdDebit: 'debit-account-789', + accountExternalIdCredit: 'credit-account-012', + transferTypeId: 1, + value: 200, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + 'debit-account-789', + 'credit-account-012', + expect.any(Number), + expect.any(Date), + expect.any(Object), + ); + }); + + it('should pass transfer type ID to use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-type', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 3, + value: 150, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.any(String), + expect.any(String), + 3, + expect.any(Date), + expect.any(Object), + ); + }); + + it('should convert createdAt string to Date object', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-date', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 100, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-06-15T14:30:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-06-15T14:30:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + const callArgs = mockValidateUseCase.execute.mock.calls[0]; + const passedDate = callArgs[5]; + + expect(passedDate).toBeInstanceOf(Date); + expect(passedDate.toISOString()).toBe('2024-06-15T14:30:00.000Z'); + }); + + it('should pass metadata to use case', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-metadata', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 100, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation-abc', + causationId: 'causation-xyz', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + version: '1.0.0', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Date), + { + correlationId: 'correlation-abc', + causationId: 'causation-xyz', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + version: '1.0.0', + }, + ); + }); + + it('should handle all parameters correctly', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-complete', + accountExternalIdDebit: 'debit-complete', + accountExternalIdCredit: 'credit-complete', + transferTypeId: 2, + value: 999.99, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-03-20T12:00:00.000Z', + metadata: { + correlationId: 'correlation-complete', + causationId: 'causation-complete', + timestamp: '2024-03-20T12:00:00.000Z', + service: 'TransactionService', + version: '1.0.0', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + 'txn-complete', + 999.99, + 'debit-complete', + 'credit-complete', + 2, + new Date('2024-03-20T12:00:00.000Z'), + event.metadata, + ); + }); + + it('should handle integer values', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-integer', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 1000, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + 1000, + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Date), + expect.any(Object), + ); + }); + + it('should handle decimal values', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-decimal', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 123.45, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + 123.45, + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Date), + expect.any(Object), + ); + }); + + it('should handle different transfer types', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const transferTypes = [1, 2, 3]; + + for (const typeId of transferTypes) { + const event: TransactionCreatedEvent = { + transactionExternalId: `txn-type-${typeId}`, + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: typeId, + value: 100, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload = createPayload(event); + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.any(String), + expect.any(String), + typeId, + expect.any(Date), + expect.any(Object), + ); + } + }); + + it('should parse JSON message correctly', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-json', + accountExternalIdDebit: 'debit-json', + accountExternalIdCredit: 'credit-json', + transferTypeId: 1, + value: 555, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation-json', + causationId: 'causation-json', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const messageValue = Buffer.from(JSON.stringify(event)); + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_CREATED, + partition: 0, + message: { + key: Buffer.from('txn-json'), + value: messageValue, + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler(payload); + + expect(mockValidateUseCase.execute).toHaveBeenCalledWith( + 'txn-json', + 555, + 'debit-json', + 'credit-json', + 1, + expect.any(Date), + event.metadata, + ); + }); + }); + + describe('error propagation', () => { + it('should propagate use case errors', async () => { + mockValidateUseCase.execute.mockRejectedValue(new Error('Validation error')); + + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-error', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 100, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T10:00:00.000Z', + metadata: { + correlationId: 'correlation', + causationId: 'causation', + timestamp: '2024-01-01T10:00:00.000Z', + service: 'TransactionService', + }, + }; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_CREATED, + partition: 0, + message: { + key: Buffer.from('txn-error'), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await expect(registeredHandler(payload)).rejects.toThrow('Validation error'); + }); + + it('should handle null message value gracefully', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_CREATED, + partition: 0, + message: { + key: Buffer.from('txn-null'), + value: null as any, + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler(payload); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Received message with null value'); + expect(mockValidateUseCase.execute).not.toHaveBeenCalled(); + }); + + it('should propagate JSON parse errors', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls[0][1]; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_CREATED, + partition: 0, + message: { + key: Buffer.from('txn-invalid'), + value: Buffer.from('invalid json{'), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await expect(registeredHandler(payload)).rejects.toThrow(); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.ts b/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.ts new file mode 100644 index 0000000000..5a27390ae4 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/messaging/transaction-created.consumer.ts @@ -0,0 +1,41 @@ +import { KafkaConsumerService, KafkaTopics, type TransactionCreatedEvent } from '@app/common'; +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; +import type { EachMessagePayload } from 'kafkajs'; +import { + type IValidateTransactionUseCase, + VALIDATE_TRANSACTION_USE_CASE, +} from '../../domain/ports/input/validate-transaction.use-case.interface'; + +@Injectable() +export class TransactionCreatedConsumer implements OnModuleInit { + constructor( + private readonly kafkaConsumer: KafkaConsumerService, + @Inject(VALIDATE_TRANSACTION_USE_CASE) + private readonly validateTransactionUseCase: IValidateTransactionUseCase, + ) {} + + async onModuleInit() { + this.kafkaConsumer.registerHandler(KafkaTopics.TRANSACTION_CREATED, this.handleTransactionCreated.bind(this)); + + await this.kafkaConsumer.subscribeAndRun([KafkaTopics.TRANSACTION_CREATED]); + } + + private async handleTransactionCreated(payload: EachMessagePayload) { + const { message } = payload; + if (!message.value) { + console.error('Received message with null value'); + return; + } + const transaction: TransactionCreatedEvent = JSON.parse(message.value.toString()); + + await this.validateTransactionUseCase.execute( + transaction.transactionExternalId, + transaction.value, + transaction.accountExternalIdDebit, + transaction.accountExternalIdCredit, + transaction.transferTypeId, + new Date(transaction.createdAt), + transaction.metadata, + ); + } +} diff --git a/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.spec.ts b/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.spec.ts new file mode 100644 index 0000000000..2e867b41eb --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.spec.ts @@ -0,0 +1,322 @@ +import type { DrizzleClient } from '@app/common'; +import { FraudAction, FraudRuleType } from '@app/common/types/fraud-rules.types'; +import { FraudRule } from '../../domain/entities/fraud-rule.entity'; +import type { FraudRuleExecution } from '../../domain/types/fraud-rule-execution.type'; +import { FraudRuleRepository } from './fraud-rule.repository'; + +describe('FraudRuleRepository', () => { + let repository: FraudRuleRepository; + let mockDb: jest.Mocked; + + beforeEach(() => { + const mockOrderBy = jest.fn().mockResolvedValue([]); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + const mockValues = jest.fn().mockResolvedValue(undefined); + + mockDb = { + select: jest.fn().mockReturnValue({ from: mockFrom }), + insert: jest.fn().mockReturnValue({ values: mockValues }), + } as any; + + repository = new FraudRuleRepository(mockDb); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findActiveRules', () => { + it('should return all active rules ordered by priority', async () => { + const dbRules = [ + { + id: 'rule-1', + name: 'High Priority Rule', + description: 'First rule', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 1000 }, + action: FraudAction.REJECT, + priority: 1, + isActive: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: 'rule-2', + name: 'Low Priority Rule', + description: 'Second rule', + ruleType: FraudRuleType.ACCOUNT_BLACKLIST, + condition: { accounts: ['acc-123'] }, + action: FraudAction.FLAG, + priority: 2, + isActive: true, + createdAt: new Date('2024-01-02'), + updatedAt: new Date('2024-01-02'), + }, + ]; + + const mockOrderBy = jest.fn().mockResolvedValue(dbRules); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findActiveRules(); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockFrom).toHaveBeenCalled(); + expect(mockWhere).toHaveBeenCalled(); + expect(mockOrderBy).toHaveBeenCalled(); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(FraudRule); + expect(result[0].id).toBe('rule-1'); + expect(result[0].priority).toBe(1); + expect(result[1].id).toBe('rule-2'); + expect(result[1].priority).toBe(2); + }); + + it('should return empty array when no active rules exist', async () => { + const mockOrderBy = jest.fn().mockResolvedValue([]); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findActiveRules(); + + expect(result).toEqual([]); + }); + + it('should filter by isActive = true', async () => { + const mockOrderBy = jest.fn().mockResolvedValue([]); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + await repository.findActiveRules(); + + expect(mockWhere).toHaveBeenCalled(); + }); + + it('should map all rule types correctly', async () => { + const ruleTypes = [ + FraudRuleType.AMOUNT_THRESHOLD, + FraudRuleType.ACCOUNT_BLACKLIST, + FraudRuleType.TRANSFER_TYPE_LIMIT, + FraudRuleType.TIME_BASED, + FraudRuleType.DAILY_LIMIT, + FraudRuleType.VELOCITY_CHECK, + ]; + + const dbRules = ruleTypes.map((ruleType, index) => ({ + id: `rule-${index}`, + name: `Rule ${index}`, + description: `Description ${index}`, + ruleType, + condition: {}, + action: FraudAction.FLAG, + priority: index + 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + })); + + const mockOrderBy = jest.fn().mockResolvedValue(dbRules); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findActiveRules(); + + expect(result).toHaveLength(6); + result.forEach((rule, index) => { + expect(rule.ruleType).toBe(ruleTypes[index]); + }); + }); + + it('should map all fraud actions correctly', async () => { + const actions = [FraudAction.APPROVE, FraudAction.FLAG, FraudAction.REVIEW, FraudAction.REJECT]; + + const dbRules = actions.map((action, index) => ({ + id: `rule-${index}`, + name: `Rule ${index}`, + description: `Description ${index}`, + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: { operator: 'gt', value: 100 }, + action, + priority: index + 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + })); + + const mockOrderBy = jest.fn().mockResolvedValue(dbRules); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findActiveRules(); + + expect(result).toHaveLength(4); + result.forEach((rule, index) => { + expect(rule.action).toBe(actions[index]); + }); + }); + + it('should preserve complex JSONB conditions', async () => { + const complexCondition = { + operator: 'gte', + value: 5000, + metadata: { risk: 'high', region: 'LATAM' }, + }; + + const dbRules = [ + { + id: 'rule-complex', + name: 'Complex Rule', + description: 'With complex condition', + ruleType: FraudRuleType.AMOUNT_THRESHOLD, + condition: complexCondition, + action: FraudAction.REVIEW, + priority: 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const mockOrderBy = jest.fn().mockResolvedValue(dbRules); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findActiveRules(); + + expect(result[0].condition).toEqual(complexCondition); + }); + }); + + describe('logExecution', () => { + it('should log a matched fraud rule execution', async () => { + const execution: FraudRuleExecution = { + ruleId: 'rule-123', + transactionExternalId: 'txn-456', + matched: true, + action: FraudAction.REJECT, + details: { amount: 5000, threshold: 1000 }, + }; + + const mockValues = jest.fn().mockResolvedValue(undefined); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.logExecution(execution); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockValues).toHaveBeenCalledWith({ + ruleId: 'rule-123', + transactionExternalId: 'txn-456', + matched: true, + action: FraudAction.REJECT, + details: { amount: 5000, threshold: 1000 }, + }); + }); + + it('should log a non-matched fraud rule execution', async () => { + const execution: FraudRuleExecution = { + ruleId: 'rule-789', + transactionExternalId: 'txn-999', + matched: false, + action: FraudAction.APPROVE, + details: {}, + }; + + const mockValues = jest.fn().mockResolvedValue(undefined); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.logExecution(execution); + + expect(mockValues).toHaveBeenCalledWith({ + ruleId: 'rule-789', + transactionExternalId: 'txn-999', + matched: false, + action: FraudAction.APPROVE, + details: {}, + }); + }); + + it('should handle executions with complex details', async () => { + const execution: FraudRuleExecution = { + ruleId: 'rule-complex', + transactionExternalId: 'txn-complex', + matched: true, + action: FraudAction.FLAG, + details: { + value: 2500, + threshold: 1000, + debitAccount: 'acc-debit', + creditAccount: 'acc-credit', + transferTypeId: 1, + metadata: { + risk: 'medium', + confidence: 0.85, + }, + }, + }; + + const mockValues = jest.fn().mockResolvedValue(undefined); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.logExecution(execution); + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + details: execution.details, + }), + ); + }); + + it('should log executions for all action types', async () => { + const actions = [FraudAction.APPROVE, FraudAction.FLAG, FraudAction.REVIEW, FraudAction.REJECT]; + + for (const action of actions) { + const execution: FraudRuleExecution = { + ruleId: `rule-${action}`, + transactionExternalId: 'txn-test', + matched: true, + action, + details: {}, + }; + + const mockValues = jest.fn().mockResolvedValue(undefined); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.logExecution(execution); + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + action, + }), + ); + } + }); + + it('should handle empty details object', async () => { + const execution: FraudRuleExecution = { + ruleId: 'rule-empty', + transactionExternalId: 'txn-empty', + matched: false, + action: FraudAction.APPROVE, + details: {}, + }; + + const mockValues = jest.fn().mockResolvedValue(undefined); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.logExecution(execution); + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + details: {}, + }), + ); + }); + }); +}); diff --git a/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.ts b/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.ts new file mode 100644 index 0000000000..4769a28651 --- /dev/null +++ b/apps/anti-fraud-service/src/infrastructure/repositories/fraud-rule.repository.ts @@ -0,0 +1,38 @@ +import { DRIZZLE_CLIENT, type DrizzleClient } from '@app/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import type { FraudRule } from '../../domain/entities/fraud-rule.entity'; +import type { IFraudRuleRepository } from '../../domain/repositories/fraud-rule.repository.interface'; +import type { FraudRuleExecution } from '../../domain/types/fraud-rule-execution.type'; +import { fraudRuleExecutions, fraudRules, type NewFraudRuleExecution } from '../database/fraud-rules.schema'; +import { FraudRuleMapper } from '../mappers/fraud-rule.mapper'; + +@Injectable() +export class FraudRuleRepository implements IFraudRuleRepository { + constructor( + @Inject(DRIZZLE_CLIENT) + private readonly db: DrizzleClient, + ) {} + + async findActiveRules(): Promise { + const rules = await this.db + .select() + .from(fraudRules) + .where(eq(fraudRules.isActive, true)) + .orderBy(fraudRules.priority); + + return rules.map(FraudRuleMapper.toDomain); + } + + async logExecution(execution: FraudRuleExecution): Promise { + const schemaExecution: NewFraudRuleExecution = { + ruleId: execution.ruleId, + transactionExternalId: execution.transactionExternalId, + matched: execution.matched, + action: execution.action, + details: execution.details, + }; + + await this.db.insert(fraudRuleExecutions).values(schemaExecution); + } +} diff --git a/apps/anti-fraud-service/src/main.ts b/apps/anti-fraud-service/src/main.ts new file mode 100644 index 0000000000..c511fca75a --- /dev/null +++ b/apps/anti-fraud-service/src/main.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; +import './config/instrument'; +import { CorrelationIdInterceptor } from '@app/common'; +import { HttpLoggerInterceptor, PinoLoggerService, shutdownTelemetry } from '@app/observability'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AntiFraudModule } from './anti-fraud.module'; + +async function bootstrap() { + const app = await NestFactory.create(AntiFraudModule, { + bufferLogs: true, + }); + const logger = app.get(PinoLoggerService); + app.useLogger(logger); + app.useGlobalInterceptors(new CorrelationIdInterceptor()); + app.useGlobalInterceptors(new HttpLoggerInterceptor(logger)); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.enableCors(); + const port = process.env.ANTI_FRAUD_PORT || 3001; + await app.listen(port); + logger.log(`🛡️ Anti-Fraud Service running on http://localhost:${port}`, { + port, + environment: process.env.NODE_ENV, + }); + process.on('SIGTERM', async () => { + logger.log('SIGTERM signal received: closing HTTP server'); + await app.close(); + await shutdownTelemetry(); + process.exit(0); + }); + process.on('SIGINT', async () => { + logger.log('SIGINT signal received: closing HTTP server'); + await app.close(); + await shutdownTelemetry(); + process.exit(0); + }); +} + +bootstrap(); diff --git a/apps/anti-fraud-service/tsconfig.app.json b/apps/anti-fraud-service/tsconfig.app.json new file mode 100644 index 0000000000..625a5fbfec --- /dev/null +++ b/apps/anti-fraud-service/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/anti-fraud-service" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts", "scripts"] +} diff --git a/apps/transaction-service/Dockerfile b/apps/transaction-service/Dockerfile new file mode 100644 index 0000000000..33b4d9b4a1 --- /dev/null +++ b/apps/transaction-service/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig*.json ./ +COPY nest-cli.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY apps/transaction-service ./apps/transaction-service +COPY apps/anti-fraud-service ./apps/anti-fraud-service +COPY libs ./libs +COPY config/database/drizzle.config.ts ./config/database/ +COPY drizzle ./drizzle + +# Build application +RUN npm run build:transaction + +# Production image +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package*.json ./ +RUN npm install --only=production && npm cache clean --force + +# Copy built application from builder (maintain directory structure) +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/config/database/drizzle.config.ts ./config/database/ + +# Expose port +EXPOSE 3000 + +# Start application +CMD ["node", "dist/apps/transaction-service/apps/transaction-service/src/main.js"] diff --git a/apps/transaction-service/src/application/use-cases/create-transaction.use-case.spec.ts b/apps/transaction-service/src/application/use-cases/create-transaction.use-case.spec.ts new file mode 100644 index 0000000000..81df0fb78b --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/create-transaction.use-case.spec.ts @@ -0,0 +1,262 @@ +import type { RequestContext } from '@app/common'; +import type { LoggerService } from '@app/observability'; +import { Transaction } from '@domain/entities/transaction.entity'; +import type { ITransactionRepository } from '@domain/repositories/transaction.repository.interface'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import type { IEventPublisher } from '../../domain/ports/output/event-publisher.interface'; +import { CreateTransactionUseCase } from './create-transaction.use-case'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => '123e4567-e89b-12d3-a456-426614174000'), +})); + +describe('CreateTransactionUseCase', () => { + let useCase: CreateTransactionUseCase; + let mockRepository: jest.Mocked; + let mockEventPublisher: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockRepository = { + save: jest.fn(), + findByExternalId: jest.fn(), + update: jest.fn(), + } as jest.Mocked; + + mockEventPublisher = { + publishTransactionCreated: jest.fn(), + publishTransactionStatusUpdated: jest.fn(), + } as jest.Mocked; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new CreateTransactionUseCase(mockRepository, mockEventPublisher, mockLogger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const createDto = { + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + tranferTypeId: 1, + value: 500, + }; + + const context: Partial = { + correlationId: 'correlation-123', + requestId: 'request-456', + service: 'TransactionService', + }; + + it('should create a transaction with PENDING status', async () => { + const expectedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + expect.any(Date), + ); + + mockRepository.save.mockResolvedValue(expectedTransaction); + + const result = await useCase.execute(createDto, context); + + expect(result).toEqual(expectedTransaction); + expect(result.transactionStatus).toBe(TransactionStatus.PENDING); + }); + + it('should generate a UUID for transactionExternalId', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + const result = await useCase.execute(createDto, context); + + expect(result.transactionExternalId).toBe('123e4567-e89b-12d3-a456-426614174000'); + }); + + it('should save transaction via repository', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + expect.any(Date), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + await useCase.execute(createDto, context); + + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + transactionExternalId: '123e4567-e89b-12d3-a456-426614174000', + accountExternalIdDebit: createDto.accountExternalIdDebit, + accountExternalIdCredit: createDto.accountExternalIdCredit, + transferTypeId: createDto.tranferTypeId, + value: createDto.value, + transactionStatus: TransactionStatus.PENDING, + }), + ); + }); + + it('should publish transaction created event with correct payload', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + await useCase.execute(createDto, context); + + expect(mockEventPublisher.publishTransactionCreated).toHaveBeenCalledTimes(1); + expect(mockEventPublisher.publishTransactionCreated).toHaveBeenCalledWith( + { + transactionExternalId: '123e4567-e89b-12d3-a456-426614174000', + accountExternalIdDebit: createDto.accountExternalIdDebit, + accountExternalIdCredit: createDto.accountExternalIdCredit, + transferTypeId: createDto.tranferTypeId, + value: createDto.value, + transactionStatus: TransactionStatus.PENDING, + createdAt: savedTransaction.createdAt, + }, + context, + ); + }); + + it('should log transaction creation with context', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + await useCase.execute(createDto, context); + + expect(mockLogger.log).toHaveBeenCalledWith('Creating transaction', context, { dto: createDto }); + expect(mockLogger.log).toHaveBeenCalledWith( + `Transaction created: ${savedTransaction.transactionExternalId}`, + context, + { + transactionId: savedTransaction.transactionExternalId, + value: savedTransaction.value, + }, + ); + }); + + it('should work without context', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + const result = await useCase.execute(createDto); + + expect(result).toEqual(savedTransaction); + expect(mockEventPublisher.publishTransactionCreated).toHaveBeenCalledWith(expect.any(Object), undefined); + }); + + it('should propagate repository errors', async () => { + const error = new Error('Database error'); + mockRepository.save.mockRejectedValue(error); + + await expect(useCase.execute(createDto, context)).rejects.toThrow('Database error'); + }); + + it('should propagate event publisher errors', async () => { + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + mockEventPublisher.publishTransactionCreated.mockRejectedValue(new Error('Kafka error')); + + await expect(useCase.execute(createDto, context)).rejects.toThrow('Kafka error'); + }); + + it('should handle zero value transactions', async () => { + const zeroValueDto = { ...createDto, value: 0 }; + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + zeroValueDto.accountExternalIdDebit, + zeroValueDto.accountExternalIdCredit, + zeroValueDto.tranferTypeId, + 0, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + const result = await useCase.execute(zeroValueDto, context); + + expect(result.value).toBe(0); + }); + + it('should handle large value transactions', async () => { + const largeValueDto = { ...createDto, value: 999999.99 }; + const savedTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + largeValueDto.accountExternalIdDebit, + largeValueDto.accountExternalIdCredit, + largeValueDto.tranferTypeId, + 999999.99, + TransactionStatus.PENDING, + new Date(), + ); + + mockRepository.save.mockResolvedValue(savedTransaction); + + const result = await useCase.execute(largeValueDto, context); + + expect(result.value).toBe(999999.99); + }); + }); +}); diff --git a/apps/transaction-service/src/application/use-cases/create-transaction.use-case.ts b/apps/transaction-service/src/application/use-cases/create-transaction.use-case.ts new file mode 100644 index 0000000000..eec0de1783 --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/create-transaction.use-case.ts @@ -0,0 +1,67 @@ +import type { RequestContext } from '@app/common'; +import { LoggerService } from '@app/observability'; +import { Transaction } from '@domain/entities/transaction.entity'; +import { + type ITransactionRepository, + TRANSACTION_REPOSITORY, +} from '@domain/repositories/transaction.repository.interface'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import type { ICreateTransactionUseCase } from '../../domain/ports/input/create-transaction.use-case.interface'; +import { EVENT_PUBLISHER, type IEventPublisher } from '../../domain/ports/output/event-publisher.interface'; + +export interface CreateTransactionDto { + accountExternalIdDebit: string; + accountExternalIdCredit: string; + tranferTypeId: number; + value: number; +} + +@Injectable() +export class CreateTransactionUseCase implements ICreateTransactionUseCase { + constructor( + @Inject(TRANSACTION_REPOSITORY) + private readonly transactionRepository: ITransactionRepository, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: IEventPublisher, + private readonly logger: LoggerService, + ) {} + + async execute(dto: CreateTransactionDto, context?: Partial): Promise { + this.logger.log('Creating transaction', context, { dto }); + const transactionExternalId = uuidv4(); + + const transaction = new Transaction( + transactionExternalId, + dto.accountExternalIdDebit, + dto.accountExternalIdCredit, + dto.tranferTypeId, + dto.value, + TransactionStatus.PENDING, + new Date(), + ); + + const savedTransaction = await this.transactionRepository.save(transaction); + + await this.eventPublisher.publishTransactionCreated( + { + transactionExternalId: savedTransaction.transactionExternalId, + accountExternalIdDebit: savedTransaction.accountExternalIdDebit, + accountExternalIdCredit: savedTransaction.accountExternalIdCredit, + transferTypeId: savedTransaction.transferTypeId, + value: savedTransaction.value, + transactionStatus: savedTransaction.transactionStatus, + createdAt: savedTransaction.createdAt, + }, + context, + ); + + this.logger.log(`Transaction created: ${savedTransaction.transactionExternalId}`, context, { + transactionId: savedTransaction.transactionExternalId, + value: savedTransaction.value, + }); + + return savedTransaction; + } +} diff --git a/apps/transaction-service/src/application/use-cases/get-transaction.use-case.spec.ts b/apps/transaction-service/src/application/use-cases/get-transaction.use-case.spec.ts new file mode 100644 index 0000000000..d526299518 --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/get-transaction.use-case.spec.ts @@ -0,0 +1,263 @@ +import type { RedisService } from '@app/common'; +import { Transaction } from '@domain/entities/transaction.entity'; +import type { ITransactionRepository } from '@domain/repositories/transaction.repository.interface'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { NotFoundException } from '@nestjs/common'; +import { GetTransactionUseCase } from './get-transaction.use-case'; + +describe('GetTransactionUseCase', () => { + let useCase: GetTransactionUseCase; + let mockRepository: jest.Mocked; + let mockRedisService: jest.Mocked; + + beforeEach(() => { + mockRepository = { + save: jest.fn(), + findByExternalId: jest.fn(), + update: jest.fn(), + } as jest.Mocked; + + mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + ttl: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new GetTransactionUseCase(mockRepository, mockRedisService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const transactionId = '123e4567-e89b-12d3-a456-426614174000'; + const cacheKey = `transaction:${transactionId}`; + + const mockTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + + describe('cache hit scenarios', () => { + it('should return transaction from cache when available', async () => { + const cachedData = { + transactionExternalId: transactionId, + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: 500, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + mockRedisService.get.mockResolvedValue(cachedData); + + const result = await useCase.execute(transactionId); + + expect(mockRedisService.get).toHaveBeenCalledWith(cacheKey); + expect(mockRedisService.get).toHaveBeenCalledTimes(1); + expect(mockRepository.findByExternalId).not.toHaveBeenCalled(); + expect(result.transactionExternalId).toBe(transactionId); + }); + + it('should re-hydrate Date object from cached string', async () => { + const cachedData = { + transactionExternalId: transactionId, + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: 500, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + mockRedisService.get.mockResolvedValue(cachedData); + + const result = await useCase.execute(transactionId); + + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should not call repository when cache hit', async () => { + const cachedData = { + transactionExternalId: transactionId, + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: 500, + transactionStatus: TransactionStatus.PENDING, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + mockRedisService.get.mockResolvedValue(cachedData); + + await useCase.execute(transactionId); + + expect(mockRepository.findByExternalId).not.toHaveBeenCalled(); + }); + }); + + describe('cache miss scenarios', () => { + it('should fetch from repository when cache misses', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId); + + expect(mockRedisService.get).toHaveBeenCalledWith(cacheKey); + expect(mockRepository.findByExternalId).toHaveBeenCalledWith(transactionId); + expect(result).toEqual(mockTransaction); + }); + + it('should cache transaction after fetching from repository', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId); + + expect(mockRedisService.set).toHaveBeenCalledWith(cacheKey, mockTransaction, 600); + }); + + it('should set 10 minutes (600 seconds) TTL when caching', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId); + + expect(mockRedisService.set).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 600); + }); + + it('should throw NotFoundException when transaction not found in repository', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(null); + + await expect(useCase.execute(transactionId)).rejects.toThrow(NotFoundException); + await expect(useCase.execute(transactionId)).rejects.toThrow( + `Transaction with id ${transactionId} not found`, + ); + }); + + it('should not cache when transaction not found', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(null); + + await expect(useCase.execute(transactionId)).rejects.toThrow(NotFoundException); + + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should propagate Redis get errors', async () => { + const redisError = new Error('Redis connection error'); + mockRedisService.get.mockRejectedValue(redisError); + + await expect(useCase.execute(transactionId)).rejects.toThrow('Redis connection error'); + }); + + it('should propagate repository errors', async () => { + mockRedisService.get.mockResolvedValue(null); + const dbError = new Error('Database error'); + mockRepository.findByExternalId.mockRejectedValue(dbError); + + await expect(useCase.execute(transactionId)).rejects.toThrow('Database error'); + }); + + it('should continue execution even if Redis set fails', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRedisService.set.mockRejectedValue(new Error('Redis set error')); + + await expect(useCase.execute(transactionId)).rejects.toThrow('Redis set error'); + expect(mockRepository.findByExternalId).toHaveBeenCalled(); + }); + }); + + describe('cache key generation', () => { + it('should use correct cache key format', async () => { + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId); + + expect(mockRedisService.get).toHaveBeenCalledWith(`transaction:${transactionId}`); + }); + + it('should handle different transaction IDs', async () => { + const differentId = '987e6543-e89b-12d3-a456-999999999999'; + const differentTransaction = new Transaction( + differentId, + 'debit-account-999', + 'credit-account-999', + 2, + 1000, + TransactionStatus.APPROVED, + new Date(), + ); + + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(differentTransaction); + + await useCase.execute(differentId); + + expect(mockRedisService.get).toHaveBeenCalledWith(`transaction:${differentId}`); + expect(mockRedisService.set).toHaveBeenCalledWith( + `transaction:${differentId}`, + differentTransaction, + 600, + ); + }); + }); + + describe('transaction status scenarios', () => { + it('should cache and return APPROVED transaction', async () => { + const approvedTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.APPROVED, + new Date(), + ); + + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(approvedTransaction); + + const result = await useCase.execute(transactionId); + + expect(result.transactionStatus).toBe(TransactionStatus.APPROVED); + expect(mockRedisService.set).toHaveBeenCalled(); + }); + + it('should cache and return REJECTED transaction', async () => { + const rejectedTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.REJECTED, + new Date(), + ); + + mockRedisService.get.mockResolvedValue(null); + mockRepository.findByExternalId.mockResolvedValue(rejectedTransaction); + + const result = await useCase.execute(transactionId); + + expect(result.transactionStatus).toBe(TransactionStatus.REJECTED); + expect(mockRedisService.set).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/transaction-service/src/application/use-cases/get-transaction.use-case.ts b/apps/transaction-service/src/application/use-cases/get-transaction.use-case.ts new file mode 100644 index 0000000000..9f2f2f1329 --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/get-transaction.use-case.ts @@ -0,0 +1,40 @@ +import { RedisService } from '@app/common'; +import type { Transaction } from '@domain/entities/transaction.entity'; +import { + type ITransactionRepository, + TRANSACTION_REPOSITORY, +} from '@domain/repositories/transaction.repository.interface'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; + +import type { IGetTransactionUseCase } from '../../domain/ports/input/get-transaction.use-case.interface'; + +@Injectable() +export class GetTransactionUseCase implements IGetTransactionUseCase { + constructor( + @Inject(TRANSACTION_REPOSITORY) + private readonly transactionRepository: ITransactionRepository, + private readonly redisService: RedisService, + ) {} + + async execute(transactionExternalId: string): Promise { + const cacheKey = `transaction:${transactionExternalId}`; + const cached = await this.redisService.get(cacheKey); + + if (cached) { + return { + ...cached, + createdAt: new Date(cached.createdAt), + } as Transaction; + } + + const transaction = await this.transactionRepository.findByExternalId(transactionExternalId); + + if (!transaction) { + throw new NotFoundException(`Transaction with id ${transactionExternalId} not found`); + } + + await this.redisService.set(cacheKey, transaction, 600); + + return transaction; + } +} diff --git a/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.spec.ts b/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.spec.ts new file mode 100644 index 0000000000..f6e4db9657 --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.spec.ts @@ -0,0 +1,296 @@ +import type { RedisService } from '@app/common'; +import type { LoggerService } from '@app/observability'; +import { Transaction } from '@domain/entities/transaction.entity'; +import type { ITransactionRepository } from '@domain/repositories/transaction.repository.interface'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { NotFoundException } from '@nestjs/common'; +import { UpdateTransactionStatusUseCase } from './update-transaction-status.use-case'; + +describe('UpdateTransactionStatusUseCase', () => { + let useCase: UpdateTransactionStatusUseCase; + let mockRepository: jest.Mocked; + let mockLogger: jest.Mocked; + let mockRedisService: jest.Mocked; + + beforeEach(() => { + mockRepository = { + save: jest.fn(), + findByExternalId: jest.fn(), + update: jest.fn(), + } as jest.Mocked; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + } as unknown as jest.Mocked; + + mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + ttl: jest.fn(), + } as unknown as jest.Mocked; + + useCase = new UpdateTransactionStatusUseCase(mockRepository, mockLogger, mockRedisService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const transactionId = '123e4567-e89b-12d3-a456-426614174000'; + const cacheKey = `transaction:${transactionId}`; + + let mockTransaction: Transaction; + + beforeEach(() => { + mockTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + }); + + describe('successful status updates', () => { + it('should update transaction status to APPROVED', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(result.transactionStatus).toBe(TransactionStatus.APPROVED); + expect(mockRepository.update).toHaveBeenCalledWith(mockTransaction); + }); + + it('should update transaction status to REJECTED', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.REJECTED); + + expect(result.transactionStatus).toBe(TransactionStatus.REJECTED); + expect(mockRepository.update).toHaveBeenCalledWith(mockTransaction); + }); + + it('should call approve() method when status is APPROVED', async () => { + const approveSpy = jest.spyOn(mockTransaction, 'approve'); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(approveSpy).toHaveBeenCalledTimes(1); + }); + + it('should call reject() method when status is REJECTED', async () => { + const rejectSpy = jest.spyOn(mockTransaction, 'reject'); + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.REJECTED); + + expect(rejectSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('repository interactions', () => { + it('should fetch transaction by external ID', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockRepository.findByExternalId).toHaveBeenCalledWith(transactionId); + expect(mockRepository.findByExternalId).toHaveBeenCalledTimes(1); + }); + + it('should throw NotFoundException when transaction not found', async () => { + mockRepository.findByExternalId.mockResolvedValue(null); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow( + NotFoundException, + ); + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow( + `Transaction with id ${transactionId} not found`, + ); + }); + + it('should not update repository when transaction not found', async () => { + mockRepository.findByExternalId.mockResolvedValue(null); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow(); + + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it('should update transaction in repository', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockRepository.update).toHaveBeenCalledWith(mockTransaction); + expect(mockRepository.update).toHaveBeenCalledTimes(1); + }); + }); + + describe('cache updates', () => { + it('should update cache after successful status update', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockRedisService.set).toHaveBeenCalledWith(cacheKey, mockTransaction, 600); + }); + + it('should set 10 minutes (600 seconds) TTL for cache', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockRedisService.set).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 600); + }); + + it('should use correct cache key format', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockRedisService.set).toHaveBeenCalledWith( + `transaction:${transactionId}`, + expect.any(Object), + 600, + ); + }); + + it('should not update cache when transaction not found', async () => { + mockRepository.findByExternalId.mockResolvedValue(null); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow(); + + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + }); + + describe('logging', () => { + it('should log APPROVED status update', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(mockLogger.log).toHaveBeenCalledWith(`Transaction approved: ${transactionId}`, undefined, { + transactionId, + status: TransactionStatus.APPROVED, + }); + }); + + it('should log REJECTED status update', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + await useCase.execute(transactionId, TransactionStatus.REJECTED); + + expect(mockLogger.log).toHaveBeenCalledWith(`Transaction rejected: ${transactionId}`, undefined, { + transactionId, + status: TransactionStatus.REJECTED, + }); + }); + }); + + describe('error handling', () => { + it('should propagate repository findByExternalId errors', async () => { + const error = new Error('Database connection error'); + mockRepository.findByExternalId.mockRejectedValue(error); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow( + 'Database connection error', + ); + }); + + it('should propagate repository update errors', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + const error = new Error('Update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow( + 'Update failed', + ); + }); + + it('should propagate Redis errors', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + mockRedisService.set.mockRejectedValue(new Error('Redis error')); + + await expect(useCase.execute(transactionId, TransactionStatus.APPROVED)).rejects.toThrow('Redis error'); + }); + }); + + describe('edge cases', () => { + it('should handle PENDING status (no-op)', async () => { + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.PENDING); + + expect(result.transactionStatus).toBe(TransactionStatus.PENDING); + expect(mockRepository.update).toHaveBeenCalled(); + }); + + it('should handle updating from APPROVED to REJECTED', async () => { + mockTransaction.transactionStatus = TransactionStatus.APPROVED; + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.REJECTED); + + expect(result.transactionStatus).toBe(TransactionStatus.REJECTED); + }); + + it('should handle updating from REJECTED to APPROVED', async () => { + mockTransaction.transactionStatus = TransactionStatus.REJECTED; + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(result.transactionStatus).toBe(TransactionStatus.APPROVED); + }); + + it('should handle idempotent APPROVED updates', async () => { + mockTransaction.transactionStatus = TransactionStatus.APPROVED; + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.APPROVED); + + expect(result.transactionStatus).toBe(TransactionStatus.APPROVED); + expect(mockRepository.update).toHaveBeenCalled(); + }); + + it('should handle idempotent REJECTED updates', async () => { + mockTransaction.transactionStatus = TransactionStatus.REJECTED; + mockRepository.findByExternalId.mockResolvedValue(mockTransaction); + mockRepository.update.mockResolvedValue(mockTransaction); + + const result = await useCase.execute(transactionId, TransactionStatus.REJECTED); + + expect(result.transactionStatus).toBe(TransactionStatus.REJECTED); + expect(mockRepository.update).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.ts b/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.ts new file mode 100644 index 0000000000..03b134fdeb --- /dev/null +++ b/apps/transaction-service/src/application/use-cases/update-transaction-status.use-case.ts @@ -0,0 +1,47 @@ +import { RedisService } from '@app/common'; +import { LoggerService } from '@app/observability'; +import type { Transaction } from '@domain/entities/transaction.entity'; +import { + type ITransactionRepository, + TRANSACTION_REPOSITORY, +} from '@domain/repositories/transaction.repository.interface'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; + +import type { IUpdateTransactionStatusUseCase } from '../../domain/ports/input/update-transaction-status.use-case.interface'; + +@Injectable() +export class UpdateTransactionStatusUseCase implements IUpdateTransactionStatusUseCase { + constructor( + @Inject(TRANSACTION_REPOSITORY) + private readonly transactionRepository: ITransactionRepository, + private readonly logger: LoggerService, + private readonly redisService: RedisService, + ) {} + + async execute(transactionExternalId: string, status: TransactionStatus): Promise { + const transaction = await this.transactionRepository.findByExternalId(transactionExternalId); + + if (!transaction) { + throw new NotFoundException(`Transaction with id ${transactionExternalId} not found`); + } + + if (status === TransactionStatus.APPROVED) { + transaction.approve(); + } else if (status === TransactionStatus.REJECTED) { + transaction.reject(); + } + + const updatedTransaction = await this.transactionRepository.update(transaction); + + const cacheKey = `transaction:${transactionExternalId}`; + await this.redisService.set(cacheKey, updatedTransaction, 600); + + this.logger.log(`Transaction ${status}: ${transactionExternalId}`, undefined, { + transactionId: transactionExternalId, + status, + }); + + return updatedTransaction; + } +} diff --git a/apps/transaction-service/src/config/instrument.ts b/apps/transaction-service/src/config/instrument.ts new file mode 100644 index 0000000000..c1eede980a --- /dev/null +++ b/apps/transaction-service/src/config/instrument.ts @@ -0,0 +1,30 @@ +require('dotenv').config(); + +import { initializeTelemetry } from '@app/observability'; + +initializeTelemetry({ + serviceName: 'transaction-service', + serviceVersion: '1.0.0', + otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318', + environment: process.env.NODE_ENV || 'development', + sampling: Number.parseFloat(process.env.OTEL_SAMPLING_RATIO || '1.0'), + enabled: process.env.OTEL_ENABLED !== 'false', +}); + +console.log('✅ OpenTelemetry initialized for transaction-service'); + +import * as Sentry from '@sentry/nestjs'; + +if (process.env.SENTRY_ENABLED === 'true') { + Sentry.init({ + dsn: + process.env.SENTRY_DSN || + 'https://355fd73ebe31d920ebd6c57263a8c28f@o4507779425435648.ingest.us.sentry.io/4510643730579456', + environment: process.env.NODE_ENV || 'development', + sendDefaultPii: true, + tracesSampleRate: 1, + integrations: [Sentry.captureConsoleIntegration()], + }); + + console.log('✅ Sentry initialized for transaction-service'); +} diff --git a/apps/transaction-service/src/domain/entities/transaction.entity.spec.ts b/apps/transaction-service/src/domain/entities/transaction.entity.spec.ts new file mode 100644 index 0000000000..35dedc68e9 --- /dev/null +++ b/apps/transaction-service/src/domain/entities/transaction.entity.spec.ts @@ -0,0 +1,178 @@ +import { TransactionStatus } from '../value-objects/transaction-status.vo'; +import { Transaction } from './transaction.entity'; + +describe('Transaction Entity', () => { + let transaction: Transaction; + + beforeEach(() => { + transaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + 'account-debit-123', + 'account-credit-456', + 1, + 500, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + }); + + describe('constructor', () => { + it('should create a transaction with all properties', () => { + expect(transaction.transactionExternalId).toBe('123e4567-e89b-12d3-a456-426614174000'); + expect(transaction.accountExternalIdDebit).toBe('account-debit-123'); + expect(transaction.accountExternalIdCredit).toBe('account-credit-456'); + expect(transaction.transferTypeId).toBe(1); + expect(transaction.value).toBe(500); + expect(transaction.transactionStatus).toBe(TransactionStatus.PENDING); + expect(transaction.createdAt).toEqual(new Date('2024-01-01T00:00:00Z')); + }); + + it('should set default createdAt to current date when not provided', () => { + const now = new Date(); + const txn = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + 'account-debit-123', + 'account-credit-456', + 1, + 500, + TransactionStatus.PENDING, + ); + + expect(txn.createdAt.getTime()).toBeGreaterThanOrEqual(now.getTime()); + }); + }); + + describe('approve', () => { + it('should change status to APPROVED', () => { + transaction.approve(); + expect(transaction.transactionStatus).toBe(TransactionStatus.APPROVED); + }); + + it('should allow approving from PENDING status', () => { + transaction.transactionStatus = TransactionStatus.PENDING; + transaction.approve(); + expect(transaction.transactionStatus).toBe(TransactionStatus.APPROVED); + }); + + it('should allow approving from REJECTED status', () => { + transaction.transactionStatus = TransactionStatus.REJECTED; + transaction.approve(); + expect(transaction.transactionStatus).toBe(TransactionStatus.APPROVED); + }); + }); + + describe('reject', () => { + it('should change status to REJECTED', () => { + transaction.reject(); + expect(transaction.transactionStatus).toBe(TransactionStatus.REJECTED); + }); + + it('should allow rejecting from PENDING status', () => { + transaction.transactionStatus = TransactionStatus.PENDING; + transaction.reject(); + expect(transaction.transactionStatus).toBe(TransactionStatus.REJECTED); + }); + + it('should allow rejecting from APPROVED status', () => { + transaction.transactionStatus = TransactionStatus.APPROVED; + transaction.reject(); + expect(transaction.transactionStatus).toBe(TransactionStatus.REJECTED); + }); + }); + + describe('isPending', () => { + it('should return true when status is PENDING', () => { + transaction.transactionStatus = TransactionStatus.PENDING; + expect(transaction.isPending()).toBe(true); + }); + + it('should return false when status is APPROVED', () => { + transaction.transactionStatus = TransactionStatus.APPROVED; + expect(transaction.isPending()).toBe(false); + }); + + it('should return false when status is REJECTED', () => { + transaction.transactionStatus = TransactionStatus.REJECTED; + expect(transaction.isPending()).toBe(false); + }); + }); + + describe('isApproved', () => { + it('should return true when status is APPROVED', () => { + transaction.transactionStatus = TransactionStatus.APPROVED; + expect(transaction.isApproved()).toBe(true); + }); + + it('should return false when status is PENDING', () => { + transaction.transactionStatus = TransactionStatus.PENDING; + expect(transaction.isApproved()).toBe(false); + }); + + it('should return false when status is REJECTED', () => { + transaction.transactionStatus = TransactionStatus.REJECTED; + expect(transaction.isApproved()).toBe(false); + }); + }); + + describe('isRejected', () => { + it('should return true when status is REJECTED', () => { + transaction.transactionStatus = TransactionStatus.REJECTED; + expect(transaction.isRejected()).toBe(true); + }); + + it('should return false when status is PENDING', () => { + transaction.transactionStatus = TransactionStatus.PENDING; + expect(transaction.isRejected()).toBe(false); + }); + + it('should return false when status is APPROVED', () => { + transaction.transactionStatus = TransactionStatus.APPROVED; + expect(transaction.isRejected()).toBe(false); + }); + }); + + describe('shouldBeRejected', () => { + it('should return true when value is greater than 1000', () => { + const highValueTxn = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + 'account-debit-123', + 'account-credit-456', + 1, + 1001, + TransactionStatus.PENDING, + ); + + expect(highValueTxn.shouldBeRejected()).toBe(true); + }); + + it('should return false when value equals 1000', () => { + const exactValueTxn = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + 'account-debit-123', + 'account-credit-456', + 1, + 1000, + TransactionStatus.PENDING, + ); + + expect(exactValueTxn.shouldBeRejected()).toBe(false); + }); + + it('should return false when value is less than 1000', () => { + expect(transaction.shouldBeRejected()).toBe(false); + }); + + it('should return false for zero value', () => { + const zeroValueTxn = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + 'account-debit-123', + 'account-credit-456', + 1, + 0, + TransactionStatus.PENDING, + ); + + expect(zeroValueTxn.shouldBeRejected()).toBe(false); + }); + }); +}); diff --git a/apps/transaction-service/src/domain/entities/transaction.entity.ts b/apps/transaction-service/src/domain/entities/transaction.entity.ts new file mode 100644 index 0000000000..a53055ae75 --- /dev/null +++ b/apps/transaction-service/src/domain/entities/transaction.entity.ts @@ -0,0 +1,37 @@ +import { TransactionStatus } from '../value-objects/transaction-status.vo'; + +export class Transaction { + constructor( + public readonly transactionExternalId: string, + public readonly accountExternalIdDebit: string, + public readonly accountExternalIdCredit: string, + public readonly transferTypeId: number, + public readonly value: number, + public transactionStatus: TransactionStatus, + public readonly createdAt: Date = new Date(), + ) {} + + approve(): void { + this.transactionStatus = TransactionStatus.APPROVED; + } + + reject(): void { + this.transactionStatus = TransactionStatus.REJECTED; + } + + isPending(): boolean { + return this.transactionStatus === TransactionStatus.PENDING; + } + + isApproved(): boolean { + return this.transactionStatus === TransactionStatus.APPROVED; + } + + isRejected(): boolean { + return this.transactionStatus === TransactionStatus.REJECTED; + } + + shouldBeRejected(): boolean { + return this.value > 1000; + } +} diff --git a/apps/transaction-service/src/domain/ports/input/create-transaction.use-case.interface.ts b/apps/transaction-service/src/domain/ports/input/create-transaction.use-case.interface.ts new file mode 100644 index 0000000000..45bda994ce --- /dev/null +++ b/apps/transaction-service/src/domain/ports/input/create-transaction.use-case.interface.ts @@ -0,0 +1,15 @@ +import type { RequestContext } from '@app/common'; +import type { Transaction } from '@domain/entities/transaction.entity'; + +export interface CreateTransactionDto { + accountExternalIdDebit: string; + accountExternalIdCredit: string; + tranferTypeId: number; + value: number; +} + +export interface ICreateTransactionUseCase { + execute(dto: CreateTransactionDto, context?: Partial): Promise; +} + +export const CREATE_TRANSACTION_USE_CASE = 'CREATE_TRANSACTION_USE_CASE'; diff --git a/apps/transaction-service/src/domain/ports/input/get-transaction.use-case.interface.ts b/apps/transaction-service/src/domain/ports/input/get-transaction.use-case.interface.ts new file mode 100644 index 0000000000..3bee7b12ca --- /dev/null +++ b/apps/transaction-service/src/domain/ports/input/get-transaction.use-case.interface.ts @@ -0,0 +1,8 @@ +import type { RequestContext } from '@app/common'; +import type { Transaction } from '@domain/entities/transaction.entity'; + +export interface IGetTransactionUseCase { + execute(transactionId: string, context?: Partial): Promise; +} + +export const GET_TRANSACTION_USE_CASE = 'GET_TRANSACTION_USE_CASE'; diff --git a/apps/transaction-service/src/domain/ports/input/update-transaction-status.use-case.interface.ts b/apps/transaction-service/src/domain/ports/input/update-transaction-status.use-case.interface.ts new file mode 100644 index 0000000000..d027ee04c1 --- /dev/null +++ b/apps/transaction-service/src/domain/ports/input/update-transaction-status.use-case.interface.ts @@ -0,0 +1,9 @@ +import type { RequestContext } from '@app/common'; +import type { Transaction } from '@domain/entities/transaction.entity'; +import type { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; + +export interface IUpdateTransactionStatusUseCase { + execute(transactionId: string, status: TransactionStatus, context?: Partial): Promise; +} + +export const UPDATE_TRANSACTION_STATUS_USE_CASE = 'UPDATE_TRANSACTION_STATUS_USE_CASE'; diff --git a/apps/transaction-service/src/domain/ports/output/event-publisher.interface.ts b/apps/transaction-service/src/domain/ports/output/event-publisher.interface.ts new file mode 100644 index 0000000000..8484355f06 --- /dev/null +++ b/apps/transaction-service/src/domain/ports/output/event-publisher.interface.ts @@ -0,0 +1,24 @@ +import type { RequestContext } from '@app/common'; +import type { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; + +export interface TransactionCreatedEvent { + transactionExternalId: string; + accountExternalIdDebit: string; + accountExternalIdCredit: string; + transferTypeId: number; + value: number; + transactionStatus: TransactionStatus; + createdAt: Date; +} + +export interface TransactionStatusUpdatedEvent { + transactionExternalId: string; + transactionStatus: TransactionStatus; +} + +export interface IEventPublisher { + publishTransactionCreated(event: TransactionCreatedEvent, context?: Partial): Promise; + publishTransactionStatusUpdated(event: TransactionStatusUpdatedEvent): Promise; +} + +export const EVENT_PUBLISHER = Symbol('EVENT_PUBLISHER'); diff --git a/apps/transaction-service/src/domain/repositories/transaction.repository.interface.ts b/apps/transaction-service/src/domain/repositories/transaction.repository.interface.ts new file mode 100644 index 0000000000..0c7b8d118a --- /dev/null +++ b/apps/transaction-service/src/domain/repositories/transaction.repository.interface.ts @@ -0,0 +1,9 @@ +import type { Transaction } from '../entities/transaction.entity'; + +export interface ITransactionRepository { + save(transaction: Transaction): Promise; + findByExternalId(externalId: string): Promise; + update(transaction: Transaction): Promise; +} + +export const TRANSACTION_REPOSITORY = Symbol('TRANSACTION_REPOSITORY'); diff --git a/apps/transaction-service/src/domain/value-objects/transaction-status.vo.spec.ts b/apps/transaction-service/src/domain/value-objects/transaction-status.vo.spec.ts new file mode 100644 index 0000000000..20f6599395 --- /dev/null +++ b/apps/transaction-service/src/domain/value-objects/transaction-status.vo.spec.ts @@ -0,0 +1,17 @@ +import { TransactionStatus } from './transaction-status.vo'; + +describe('TransactionStatus', () => { + it('should have correct status values', () => { + expect(TransactionStatus.PENDING).toBe('pending'); + expect(TransactionStatus.APPROVED).toBe('approved'); + expect(TransactionStatus.REJECTED).toBe('rejected'); + }); + + it('should export all required status values', () => { + const expectedStatuses = ['pending', 'approved', 'rejected']; + const actualStatuses = Object.values(TransactionStatus); + + expect(actualStatuses).toEqual(expect.arrayContaining(expectedStatuses)); + expect(actualStatuses.length).toBe(expectedStatuses.length); + }); +}); diff --git a/apps/transaction-service/src/domain/value-objects/transaction-status.vo.ts b/apps/transaction-service/src/domain/value-objects/transaction-status.vo.ts new file mode 100644 index 0000000000..1c3016ab55 --- /dev/null +++ b/apps/transaction-service/src/domain/value-objects/transaction-status.vo.ts @@ -0,0 +1,5 @@ +export enum TransactionStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', +} diff --git a/apps/transaction-service/src/domain/value-objects/transaction-type.vo.spec.ts b/apps/transaction-service/src/domain/value-objects/transaction-type.vo.spec.ts new file mode 100644 index 0000000000..bfab788c32 --- /dev/null +++ b/apps/transaction-service/src/domain/value-objects/transaction-type.vo.spec.ts @@ -0,0 +1,42 @@ +import { TransactionType } from './transaction-type.vo'; + +describe('TransactionType', () => { + describe('create', () => { + it('should create a valid transaction type for TRANSFER', () => { + const transactionType = TransactionType.create(1); + + expect(transactionType.id).toBe(1); + expect(transactionType.name).toBe('Transfer'); + }); + + it('should create a valid transaction type for PAYMENT', () => { + const transactionType = TransactionType.create(2); + + expect(transactionType.id).toBe(2); + expect(transactionType.name).toBe('Payment'); + }); + + it('should create a valid transaction type for WITHDRAWAL', () => { + const transactionType = TransactionType.create(3); + + expect(transactionType.id).toBe(3); + expect(transactionType.name).toBe('Withdrawal'); + }); + + it('should return "Unknown" for invalid transaction type id', () => { + const transactionType = TransactionType.create(999); + + expect(transactionType.id).toBe(999); + expect(transactionType.name).toBe('Unknown'); + }); + }); + + describe('constructor', () => { + it('should create transaction type with provided values', () => { + const transactionType = new TransactionType(1, 'Test Type'); + + expect(transactionType.id).toBe(1); + expect(transactionType.name).toBe('Test Type'); + }); + }); +}); diff --git a/apps/transaction-service/src/domain/value-objects/transaction-type.vo.ts b/apps/transaction-service/src/domain/value-objects/transaction-type.vo.ts new file mode 100644 index 0000000000..49925316fa --- /dev/null +++ b/apps/transaction-service/src/domain/value-objects/transaction-type.vo.ts @@ -0,0 +1,16 @@ +export class TransactionType { + constructor( + public readonly id: number, + public readonly name: string, + ) {} + + static create(id: number): TransactionType { + const types: Record = { + 1: 'Transfer', + 2: 'Payment', + 3: 'Withdrawal', + }; + + return new TransactionType(id, types[id] || 'Unknown'); + } +} diff --git a/apps/transaction-service/src/infrastructure/database/database.module.ts b/apps/transaction-service/src/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..72c0ec7a36 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/database/database.module.ts @@ -0,0 +1,24 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createDrizzleClient, type DrizzleClient } from './drizzle.config'; + +export const DRIZZLE_CLIENT = Symbol('DRIZZLE_CLIENT'); + +@Global() +@Module({ + providers: [ + { + provide: DRIZZLE_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService): DrizzleClient => { + const connectionString = configService.get('DATABASE_URL'); + if (!connectionString) { + throw new Error('DATABASE_URL environment variable is required'); + } + return createDrizzleClient(connectionString); + }, + }, + ], + exports: [DRIZZLE_CLIENT], +}) +export class DatabaseModule {} diff --git a/apps/transaction-service/src/infrastructure/database/drizzle.config.ts b/apps/transaction-service/src/infrastructure/database/drizzle.config.ts new file mode 100644 index 0000000000..b03c8a45c0 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/database/drizzle.config.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +export const createDrizzleClient = (connectionString: string) => { + const client = postgres(connectionString); + return drizzle(client, { schema }); +}; + +export type DrizzleClient = ReturnType; diff --git a/apps/transaction-service/src/infrastructure/database/schema.ts b/apps/transaction-service/src/infrastructure/database/schema.ts new file mode 100644 index 0000000000..3d666756ed --- /dev/null +++ b/apps/transaction-service/src/infrastructure/database/schema.ts @@ -0,0 +1,15 @@ +import { decimal, integer, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; + +export const transactions = pgTable('transactions', { + id: uuid('id').primaryKey().defaultRandom(), + transactionExternalId: uuid('transaction_external_id').notNull().unique(), + accountExternalIdDebit: uuid('account_external_id_debit').notNull(), + accountExternalIdCredit: uuid('account_external_id_credit').notNull(), + transferTypeId: integer('transfer_type_id').notNull(), + value: decimal('value', { precision: 10, scale: 2 }).notNull(), + transactionStatus: varchar('transaction_status', { length: 20 }).notNull().default('pending'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export type TransactionSchema = typeof transactions.$inferSelect; +export type NewTransactionSchema = typeof transactions.$inferInsert; diff --git a/apps/transaction-service/src/infrastructure/messaging/anti-fraud.service.ts b/apps/transaction-service/src/infrastructure/messaging/anti-fraud.service.ts new file mode 100644 index 0000000000..b7ee2b8341 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/anti-fraud.service.ts @@ -0,0 +1,78 @@ +import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { type Consumer, type EachMessagePayload, Kafka, type Producer } from 'kafkajs'; + +@Injectable() +export class AntiFraudService implements OnModuleInit, OnModuleDestroy { + private kafka: Kafka; + private consumer: Consumer; + private producer: Producer; + + constructor(private readonly configService: ConfigService) { + const kafkaBroker = this.configService.get('KAFKA_BROKER'); + if (!kafkaBroker) { + throw new Error('KAFKA_BROKER environment variable is required'); + } + this.kafka = new Kafka({ + clientId: 'anti-fraud-service', + brokers: [kafkaBroker], + }); + this.consumer = this.kafka.consumer({ + groupId: 'anti-fraud-service-group', + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.consumer.connect(); + await this.producer.connect(); + + await this.consumer.subscribe({ + topics: ['transaction-created'], + fromBeginning: false, + }); + + await this.consumer.run({ + eachMessage: this.validateTransaction.bind(this), + }); + + console.log('Anti-Fraud Service started and listening for transactions'); + } + + async onModuleDestroy() { + await this.consumer.disconnect(); + await this.producer.disconnect(); + } + + private async validateTransaction(payload: EachMessagePayload) { + const { message } = payload; + if (!message.value) { + console.error('Received message with null value'); + return; + } + const transaction = JSON.parse(message.value.toString()); + + console.log('Anti-Fraud validating transaction:', transaction); + + const shouldReject = transaction.value > 1000; + + const resultTopic = shouldReject ? 'transaction-rejected' : 'transaction-approved'; + + await this.producer.send({ + topic: resultTopic, + messages: [ + { + key: transaction.transactionExternalId, + value: JSON.stringify({ + transactionExternalId: transaction.transactionExternalId, + reason: shouldReject ? 'Value exceeds maximum allowed (1000)' : 'Approved', + }), + }, + ], + }); + + console.log( + `Transaction ${transaction.transactionExternalId} ${shouldReject ? 'REJECTED' : 'APPROVED'} by Anti-Fraud`, + ); + } +} diff --git a/apps/transaction-service/src/infrastructure/messaging/kafka-consumer.service.ts b/apps/transaction-service/src/infrastructure/messaging/kafka-consumer.service.ts new file mode 100644 index 0000000000..0add0ebacc --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/kafka-consumer.service.ts @@ -0,0 +1,63 @@ +import type { UpdateTransactionStatusUseCase } from '@application/use-cases/update-transaction-status.use-case'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { type Consumer, type EachMessagePayload, Kafka } from 'kafkajs'; + +@Injectable() +export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy { + private kafka: Kafka; + private consumer: Consumer; + + constructor( + private readonly configService: ConfigService, + private readonly updateTransactionStatusUseCase: UpdateTransactionStatusUseCase, + ) { + const kafkaBroker = this.configService.get('KAFKA_BROKER'); + if (!kafkaBroker) { + throw new Error('KAFKA_BROKER environment variable is required'); + } + this.kafka = new Kafka({ + clientId: 'transaction-service-consumer', + brokers: [kafkaBroker], + }); + this.consumer = this.kafka.consumer({ + groupId: 'transaction-service-group', + }); + } + + async onModuleInit() { + await this.consumer.connect(); + await this.consumer.subscribe({ + topics: ['transaction-approved', 'transaction-rejected'], + fromBeginning: false, + }); + + await this.consumer.run({ + eachMessage: this.handleMessage.bind(this), + }); + } + + async onModuleDestroy() { + await this.consumer.disconnect(); + } + + private async handleMessage(payload: EachMessagePayload) { + const { topic, message } = payload; + if (!message.value) { + console.error('Received message with null value'); + return; + } + const event = JSON.parse(message.value.toString()); + + console.log(`Received event from topic ${topic}:`, event); + + if (topic === 'transaction-approved') { + await this.updateTransactionStatusUseCase.execute(event.transactionExternalId, TransactionStatus.APPROVED); + console.log(`Transaction ${event.transactionExternalId} approved successfully`); + } else if (topic === 'transaction-rejected') { + await this.updateTransactionStatusUseCase.execute(event.transactionExternalId, TransactionStatus.REJECTED); + console.log(`Transaction ${event.transactionExternalId} rejected successfully`); + } + } +} diff --git a/apps/transaction-service/src/infrastructure/messaging/kafka-event-publisher.ts b/apps/transaction-service/src/infrastructure/messaging/kafka-event-publisher.ts new file mode 100644 index 0000000000..11ee04051c --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/kafka-event-publisher.ts @@ -0,0 +1,58 @@ +import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { Kafka, type Producer } from 'kafkajs'; +import type { + IEventPublisher, + TransactionCreatedEvent, + TransactionStatusUpdatedEvent, +} from '../../domain/ports/output/event-publisher.interface'; + +@Injectable() +export class KafkaEventPublisher implements IEventPublisher, OnModuleInit, OnModuleDestroy { + private kafka: Kafka; + private producer: Producer; + + constructor(private readonly configService: ConfigService) { + const kafkaBroker = this.configService.get('KAFKA_BROKER'); + if (!kafkaBroker) { + throw new Error('KAFKA_BROKER environment variable is required'); + } + this.kafka = new Kafka({ + clientId: 'transaction-service', + brokers: [kafkaBroker], + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.producer.connect(); + } + + async onModuleDestroy() { + await this.producer.disconnect(); + } + + async publishTransactionCreated(event: TransactionCreatedEvent): Promise { + await this.producer.send({ + topic: 'transaction-created', + messages: [ + { + key: event.transactionExternalId, + value: JSON.stringify(event), + }, + ], + }); + } + + async publishTransactionStatusUpdated(event: TransactionStatusUpdatedEvent): Promise { + await this.producer.send({ + topic: 'transaction-status-updated', + messages: [ + { + key: event.transactionExternalId, + value: JSON.stringify(event), + }, + ], + }); + } +} diff --git a/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.spec.ts b/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.spec.ts new file mode 100644 index 0000000000..73c1df56eb --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.spec.ts @@ -0,0 +1,282 @@ +import type { KafkaProducerService, RequestContext } from '@app/common'; +import { KafkaTopics, TransactionStatus } from '@app/common'; +import type { + TransactionCreatedEvent, + TransactionStatusUpdatedEvent, +} from '../../domain/ports/output/event-publisher.interface'; +import { TransactionEventService } from './transaction-event.service'; + +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(() => 'mocked-uuid-12345'), +})); + +describe('TransactionEventService', () => { + let service: TransactionEventService; + let mockKafkaProducer: jest.Mocked; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + mockKafkaProducer = { + sendMessage: jest.fn().mockResolvedValue(undefined), + } as any; + + service = new TransactionEventService(mockKafkaProducer); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleLogSpy.mockRestore(); + }); + + describe('publishTransactionCreated', () => { + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: 500.5, + transactionStatus: TransactionStatus.PENDING, + createdAt: new Date('2024-01-01T10:00:00Z'), + }; + + it('should publish transaction created event to Kafka', async () => { + await service.publishTransactionCreated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledTimes(1); + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_CREATED, + 'txn-123', + expect.objectContaining({ + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: 500.5, + transactionStatus: TransactionStatus.PENDING, + }), + ); + }); + + it('should convert createdAt Date to ISO string', async () => { + await service.publishTransactionCreated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_CREATED, + 'txn-123', + expect.objectContaining({ + createdAt: '2024-01-01T10:00:00.000Z', + }), + ); + }); + + it('should include event metadata with correlationId from context', async () => { + const context: Partial = { + correlationId: 'correlation-123', + requestId: 'request-456', + }; + + await service.publishTransactionCreated(event, context); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_CREATED, + 'txn-123', + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'correlation-123', + causationId: 'request-456', + service: 'TransactionService', + version: '1.0.0', + }), + }), + ); + }); + + it('should generate UUID for correlationId when context not provided', async () => { + await service.publishTransactionCreated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_CREATED, + 'txn-123', + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'mocked-uuid-12345', + causationId: 'mocked-uuid-12345', + }), + }), + ); + }); + + it('should include timestamp in metadata', async () => { + await service.publishTransactionCreated(event); + + const calls = mockKafkaProducer.sendMessage.mock.calls[0]; + const kafkaEvent = calls[2] as any; + + expect(kafkaEvent.metadata.timestamp).toBeDefined(); + expect(typeof kafkaEvent.metadata.timestamp).toBe('string'); + }); + + it('should log published event', async () => { + const context: Partial = { + correlationId: 'test-correlation', + }; + + await service.publishTransactionCreated(event, context); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('📤 Published transaction-created')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('txn-123')); + }); + + it('should handle partial context', async () => { + const context: Partial = { + correlationId: 'correlation-only', + }; + + await service.publishTransactionCreated(event, context); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'correlation-only', + causationId: 'mocked-uuid-12345', + }), + }), + ); + }); + }); + + describe('publishTransactionStatusUpdated', () => { + it('should publish to TRANSACTION_APPROVED topic when status is approved', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-456', + transactionStatus: TransactionStatus.APPROVED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_APPROVED, + 'txn-456', + expect.objectContaining({ + transactionExternalId: 'txn-456', + transactionStatus: TransactionStatus.APPROVED, + }), + ); + }); + + it('should publish to TRANSACTION_REJECTED topic when status is rejected', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-789', + transactionStatus: TransactionStatus.REJECTED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_REJECTED, + 'txn-789', + expect.objectContaining({ + transactionExternalId: 'txn-789', + transactionStatus: TransactionStatus.REJECTED, + }), + ); + }); + + it('should include metadata for approved status', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-approved', + transactionStatus: TransactionStatus.APPROVED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + correlationId: 'mocked-uuid-12345', + causationId: 'mocked-uuid-12345', + service: 'TransactionService', + version: '1.0.0', + }), + }), + ); + }); + + it('should include metadata for rejected status', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-rejected', + transactionStatus: TransactionStatus.REJECTED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(mockKafkaProducer.sendMessage).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ + metadata: expect.objectContaining({ + timestamp: expect.any(String), + }), + }), + ); + }); + + it('should log published approved event', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-log-test', + transactionStatus: TransactionStatus.APPROVED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('📤 Published')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('txn-log-test')); + }); + + it('should log published rejected event', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-log-reject', + transactionStatus: TransactionStatus.REJECTED, + }; + + await service.publishTransactionStatusUpdated(event); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('📤 Published')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('txn-log-reject')); + }); + }); + + describe('error handling', () => { + it('should propagate Kafka producer errors for transaction created', async () => { + const event: TransactionCreatedEvent = { + transactionExternalId: 'txn-error', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: 100, + transactionStatus: TransactionStatus.PENDING, + createdAt: new Date(), + }; + + mockKafkaProducer.sendMessage.mockRejectedValue(new Error('Kafka connection error')); + + await expect(service.publishTransactionCreated(event)).rejects.toThrow('Kafka connection error'); + }); + + it('should propagate Kafka producer errors for status updated', async () => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-error-status', + transactionStatus: TransactionStatus.APPROVED, + }; + + mockKafkaProducer.sendMessage.mockRejectedValue(new Error('Kafka timeout')); + + await expect(service.publishTransactionStatusUpdated(event)).rejects.toThrow('Kafka timeout'); + }); + }); +}); diff --git a/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.ts b/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.ts new file mode 100644 index 0000000000..39e5cff182 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/transaction-event.service.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { + type EventMetadata, + KafkaProducerService, + KafkaTopics, + type TransactionCreatedEvent as KafkaTransactionCreatedEvent, + type TransactionStatusUpdatedEvent as KafkaTransactionStatusUpdatedEvent, + type RequestContext, +} from '@app/common'; +import { Injectable } from '@nestjs/common'; +import type { + IEventPublisher, + TransactionCreatedEvent, + TransactionStatusUpdatedEvent, +} from '../../domain/ports/output/event-publisher.interface'; + +@Injectable() +export class TransactionEventService implements IEventPublisher { + constructor(private readonly kafkaProducer: KafkaProducerService) {} + + async publishTransactionCreated(event: TransactionCreatedEvent, context?: Partial): Promise { + const metadata: EventMetadata = { + correlationId: context?.correlationId || randomUUID(), + causationId: context?.requestId || randomUUID(), + timestamp: new Date().toISOString(), + service: 'TransactionService', + version: '1.0.0', + }; + + const kafkaEvent: KafkaTransactionCreatedEvent = { + ...event, + createdAt: event.createdAt.toISOString(), + metadata, + }; + + await this.kafkaProducer.sendMessage(KafkaTopics.TRANSACTION_CREATED, event.transactionExternalId, kafkaEvent); + console.log( + `📤 Published transaction-created: ${event.transactionExternalId} [correlationId: ${metadata.correlationId}]`, + ); + } + + async publishTransactionStatusUpdated(event: TransactionStatusUpdatedEvent): Promise { + const topic = + event.transactionStatus === 'approved' + ? KafkaTopics.TRANSACTION_APPROVED + : KafkaTopics.TRANSACTION_REJECTED; + + const kafkaEvent: KafkaTransactionStatusUpdatedEvent = { + ...event, + transactionStatus: event.transactionStatus as 'approved' | 'rejected', + metadata: { + correlationId: randomUUID(), + causationId: randomUUID(), + timestamp: new Date().toISOString(), + service: 'TransactionService', + version: '1.0.0', + }, + }; + + await this.kafkaProducer.sendMessage(topic, event.transactionExternalId, kafkaEvent); + console.log(`📤 Published ${topic}: ${event.transactionExternalId}`); + } +} diff --git a/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.spec.ts b/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.spec.ts new file mode 100644 index 0000000000..8d2d7c2139 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.spec.ts @@ -0,0 +1,486 @@ +import type { KafkaConsumerService, TransactionStatusUpdatedEvent } from '@app/common'; +import { KafkaTopics, TransactionStatus } from '@app/common'; +import type { LoggerService } from '@app/observability'; +import type { EachMessagePayload } from 'kafkajs'; +import type { IUpdateTransactionStatusUseCase } from '../../domain/ports/input/update-transaction-status.use-case.interface'; +import { TransactionStatusConsumer } from './transaction-status.consumer'; + +describe('TransactionStatusConsumer', () => { + let consumer: TransactionStatusConsumer; + let mockKafkaConsumer: jest.Mocked; + let mockUpdateUseCase: jest.Mocked; + let mockLogger: jest.Mocked; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + mockKafkaConsumer = { + registerHandler: jest.fn(), + subscribeAndRun: jest.fn().mockResolvedValue(undefined), + } as any; + + mockUpdateUseCase = { + execute: jest.fn().mockResolvedValue(undefined), + } as jest.Mocked; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + } as unknown as jest.Mocked; + + consumer = new TransactionStatusConsumer(mockKafkaConsumer, mockUpdateUseCase, mockLogger); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + describe('onModuleInit', () => { + it('should register handlers for approved and rejected topics', async () => { + await consumer.onModuleInit(); + + expect(mockKafkaConsumer.registerHandler).toHaveBeenCalledTimes(2); + expect(mockKafkaConsumer.registerHandler).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_APPROVED, + expect.any(Function), + ); + expect(mockKafkaConsumer.registerHandler).toHaveBeenCalledWith( + KafkaTopics.TRANSACTION_REJECTED, + expect.any(Function), + ); + }); + + it('should subscribe to approved and rejected topics', async () => { + await consumer.onModuleInit(); + + expect(mockKafkaConsumer.subscribeAndRun).toHaveBeenCalledWith([ + KafkaTopics.TRANSACTION_APPROVED, + KafkaTopics.TRANSACTION_REJECTED, + ]); + }); + + it('should subscribe to topics after registering handlers', async () => { + const callOrder: string[] = []; + + mockKafkaConsumer.registerHandler.mockImplementation(() => { + callOrder.push('registerHandler'); + }); + + mockKafkaConsumer.subscribeAndRun.mockImplementation(async () => { + callOrder.push('subscribeAndRun'); + }); + + await consumer.onModuleInit(); + + expect(callOrder).toEqual(['registerHandler', 'registerHandler', 'subscribeAndRun']); + }); + }); + + describe('handleApproved', () => { + const createApprovedPayload = (transactionId: string, correlationId: string): EachMessagePayload => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: transactionId, + transactionStatus: TransactionStatus.APPROVED, + metadata: { + correlationId, + causationId: 'causation-123', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + version: '1.0.0', + }, + }; + + return { + topic: KafkaTopics.TRANSACTION_APPROVED, + partition: 0, + message: { + key: Buffer.from(transactionId), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + }; + + it('should process approved transaction event', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + expect(registeredHandler).toBeDefined(); + + const payload = createApprovedPayload('txn-123', 'correlation-123'); + await registeredHandler?.(payload); + + expect(mockUpdateUseCase.execute).toHaveBeenCalledWith('txn-123', TransactionStatus.APPROVED); + }); + + it('should log received approved event', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const payload = createApprovedPayload('txn-456', 'correlation-456'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith('Received transaction-approved: txn-456', { + correlationId: 'correlation-456', + requestId: 'causation-123', + }); + }); + + it('should log successful update after approval', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const payload = createApprovedPayload('txn-789', 'correlation-789'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith('Transaction txn-789 marked as approved', { + correlationId: 'correlation-789', + requestId: 'causation-123', + }); + }); + + it('should extract context from event metadata', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const payload = createApprovedPayload('txn-context', 'context-correlation'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + correlationId: 'context-correlation', + requestId: 'causation-123', + }), + ); + }); + + it('should handle null message value', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_APPROVED, + partition: 0, + message: { + key: Buffer.from('txn-null'), + value: null as any, + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler?.(payload); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Received message with null value'); + expect(mockUpdateUseCase.execute).not.toHaveBeenCalled(); + }); + + it('should parse JSON message correctly', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-parse', + transactionStatus: TransactionStatus.APPROVED, + metadata: { + correlationId: 'parse-correlation', + causationId: 'parse-causation', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + }, + }; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_APPROVED, + partition: 0, + message: { + key: Buffer.from('txn-parse'), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler?.(payload); + + expect(mockUpdateUseCase.execute).toHaveBeenCalledWith('txn-parse', TransactionStatus.APPROVED); + }); + }); + + describe('handleRejected', () => { + const createRejectedPayload = (transactionId: string, correlationId: string): EachMessagePayload => { + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: transactionId, + transactionStatus: TransactionStatus.REJECTED, + metadata: { + correlationId, + causationId: 'causation-456', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + version: '1.0.0', + }, + }; + + return { + topic: KafkaTopics.TRANSACTION_REJECTED, + partition: 0, + message: { + key: Buffer.from(transactionId), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + }; + + it('should process rejected transaction event', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + expect(registeredHandler).toBeDefined(); + + const payload = createRejectedPayload('txn-rejected-1', 'correlation-rejected'); + await registeredHandler?.(payload); + + expect(mockUpdateUseCase.execute).toHaveBeenCalledWith('txn-rejected-1', TransactionStatus.REJECTED); + }); + + it('should log received rejected event', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const payload = createRejectedPayload('txn-rejected-2', 'correlation-reject-2'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith('Received transaction-rejected: txn-rejected-2', { + correlationId: 'correlation-reject-2', + requestId: 'causation-456', + }); + }); + + it('should log successful update after rejection', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const payload = createRejectedPayload('txn-rejected-3', 'correlation-reject-3'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith('Transaction txn-rejected-3 marked as rejected', { + correlationId: 'correlation-reject-3', + requestId: 'causation-456', + }); + }); + + it('should extract context from event metadata', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const payload = createRejectedPayload('txn-reject-context', 'reject-correlation'); + await registeredHandler?.(payload); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + correlationId: 'reject-correlation', + requestId: 'causation-456', + }), + ); + }); + + it('should handle null message value', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_REJECTED, + partition: 0, + message: { + key: Buffer.from('txn-null-reject'), + value: null as any, + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler?.(payload); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Received message with null value'); + expect(mockUpdateUseCase.execute).not.toHaveBeenCalled(); + }); + + it('should parse JSON message correctly', async () => { + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-parse-reject', + transactionStatus: TransactionStatus.REJECTED, + metadata: { + correlationId: 'parse-reject-correlation', + causationId: 'parse-reject-causation', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + }, + }; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_REJECTED, + partition: 0, + message: { + key: Buffer.from('txn-parse-reject'), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await registeredHandler?.(payload); + + expect(mockUpdateUseCase.execute).toHaveBeenCalledWith('txn-parse-reject', TransactionStatus.REJECTED); + }); + }); + + describe('error propagation', () => { + it('should propagate use case errors for approved transactions', async () => { + mockUpdateUseCase.execute.mockRejectedValue(new Error('Database error')); + + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_APPROVED, + )?.[1]; + + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-error', + transactionStatus: TransactionStatus.APPROVED, + metadata: { + correlationId: 'error-correlation', + causationId: 'error-causation', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + }, + }; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_APPROVED, + partition: 0, + message: { + key: Buffer.from('txn-error'), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await expect(registeredHandler?.(payload)).rejects.toThrow('Database error'); + }); + + it('should propagate use case errors for rejected transactions', async () => { + mockUpdateUseCase.execute.mockRejectedValue(new Error('Repository error')); + + await consumer.onModuleInit(); + + const registeredHandler = mockKafkaConsumer.registerHandler.mock.calls.find( + (call) => call[0] === KafkaTopics.TRANSACTION_REJECTED, + )?.[1]; + + const event: TransactionStatusUpdatedEvent = { + transactionExternalId: 'txn-error-reject', + transactionStatus: TransactionStatus.REJECTED, + metadata: { + correlationId: 'error-reject-correlation', + causationId: 'error-reject-causation', + timestamp: new Date().toISOString(), + service: 'AntiFraudService', + }, + }; + + const payload: EachMessagePayload = { + topic: KafkaTopics.TRANSACTION_REJECTED, + partition: 0, + message: { + key: Buffer.from('txn-error-reject'), + value: Buffer.from(JSON.stringify(event)), + timestamp: '1234567890', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await expect(registeredHandler?.(payload)).rejects.toThrow('Repository error'); + }); + }); +}); diff --git a/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.ts b/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.ts new file mode 100644 index 0000000000..681fc449f0 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/messaging/transaction-status.consumer.ts @@ -0,0 +1,64 @@ +import { KafkaConsumerService, KafkaTopics, TransactionStatus, type TransactionStatusUpdatedEvent } from '@app/common'; +import { LoggerService } from '@app/observability'; +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; +import type { EachMessagePayload } from 'kafkajs'; +import { + type IUpdateTransactionStatusUseCase, + UPDATE_TRANSACTION_STATUS_USE_CASE, +} from '../../domain/ports/input/update-transaction-status.use-case.interface'; + +@Injectable() +export class TransactionStatusConsumer implements OnModuleInit { + constructor( + private readonly kafkaConsumer: KafkaConsumerService, + @Inject(UPDATE_TRANSACTION_STATUS_USE_CASE) + private readonly updateTransactionStatusUseCase: IUpdateTransactionStatusUseCase, + private readonly logger: LoggerService, + ) {} + + async onModuleInit() { + this.kafkaConsumer.registerHandler(KafkaTopics.TRANSACTION_APPROVED, this.handleApproved.bind(this)); + + this.kafkaConsumer.registerHandler(KafkaTopics.TRANSACTION_REJECTED, this.handleRejected.bind(this)); + + await this.kafkaConsumer.subscribeAndRun([KafkaTopics.TRANSACTION_APPROVED, KafkaTopics.TRANSACTION_REJECTED]); + } + + private async handleApproved(payload: EachMessagePayload): Promise { + if (!payload.message.value) { + console.error('Received message with null value'); + return; + } + const event: TransactionStatusUpdatedEvent = JSON.parse(payload.message.value.toString()); + + const requestContext = { + correlationId: event.metadata.correlationId, + requestId: event.metadata.causationId, + }; + + this.logger.log(`Received transaction-approved: ${event.transactionExternalId}`, requestContext); + + await this.updateTransactionStatusUseCase.execute(event.transactionExternalId, TransactionStatus.APPROVED); + + this.logger.log(`Transaction ${event.transactionExternalId} marked as approved`, requestContext); + } + + private async handleRejected(payload: EachMessagePayload): Promise { + if (!payload.message.value) { + console.error('Received message with null value'); + return; + } + const event: TransactionStatusUpdatedEvent = JSON.parse(payload.message.value.toString()); + + const requestContext = { + correlationId: event.metadata.correlationId, + requestId: event.metadata.causationId, + }; + + this.logger.log(`Received transaction-rejected: ${event.transactionExternalId}`, requestContext); + + await this.updateTransactionStatusUseCase.execute(event.transactionExternalId, TransactionStatus.REJECTED); + + this.logger.log(`Transaction ${event.transactionExternalId} marked as rejected`, requestContext); + } +} diff --git a/apps/transaction-service/src/infrastructure/repositories/transaction.repository.spec.ts b/apps/transaction-service/src/infrastructure/repositories/transaction.repository.spec.ts new file mode 100644 index 0000000000..f17bb20e55 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/repositories/transaction.repository.spec.ts @@ -0,0 +1,365 @@ +import type { DrizzleClient } from '@app/common'; +import { Transaction } from '@domain/entities/transaction.entity'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { TransactionRepository } from './transaction.repository'; + +describe('TransactionRepository', () => { + let repository: TransactionRepository; + let mockDb: jest.Mocked; + + beforeEach(() => { + const mockReturning = jest.fn(); + const mockWhere = jest + .fn() + .mockReturnValue({ returning: mockReturning, limit: jest.fn().mockResolvedValue([]) }); + const mockSet = jest.fn().mockReturnValue({ where: mockWhere }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + const mockValues = jest.fn().mockReturnValue({ returning: mockReturning }); + + mockDb = { + insert: jest.fn().mockReturnValue({ values: mockValues }), + select: jest.fn().mockReturnValue({ from: mockFrom }), + update: jest.fn().mockReturnValue({ set: mockSet }), + } as any; + + repository = new TransactionRepository(mockDb); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('save', () => { + it('should save a transaction and return the saved entity', async () => { + const transaction = new Transaction( + 'txn-123', + 'debit-account-123', + 'credit-account-456', + 1, + 500.5, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + + const dbResult = { + id: 1, + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: '500.50', + transactionStatus: 'pending', + createdAt: new Date('2024-01-01T00:00:00Z'), + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockValues = jest.fn().mockReturnValue({ returning: mockReturning }); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + const result = await repository.save(transaction); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockValues).toHaveBeenCalledWith({ + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: '500.5', + transactionStatus: 'pending', + createdAt: transaction.createdAt, + }); + expect(result).toBeInstanceOf(Transaction); + expect(result.transactionExternalId).toBe('txn-123'); + expect(result.value).toBe(500.5); + }); + + it('should convert number value to string for database', async () => { + const transaction = new Transaction( + 'txn-456', + 'debit-account', + 'credit-account', + 2, + 1000, + TransactionStatus.PENDING, + new Date(), + ); + + const dbResult = { + transactionExternalId: 'txn-456', + accountExternalIdDebit: 'debit-account', + accountExternalIdCredit: 'credit-account', + transferTypeId: 2, + value: '1000', + transactionStatus: 'pending', + createdAt: new Date(), + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockValues = jest.fn().mockReturnValue({ returning: mockReturning }); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + await repository.save(transaction); + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + value: '1000', + }), + ); + }); + + it('should handle decimal values correctly', async () => { + const transaction = new Transaction( + 'txn-789', + 'debit-account', + 'credit-account', + 1, + 123.45, + TransactionStatus.PENDING, + new Date(), + ); + + const dbResult = { + transactionExternalId: 'txn-789', + accountExternalIdDebit: 'debit-account', + accountExternalIdCredit: 'credit-account', + transferTypeId: 1, + value: '123.45', + transactionStatus: 'pending', + createdAt: new Date(), + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockValues = jest.fn().mockReturnValue({ returning: mockReturning }); + mockDb.insert = jest.fn().mockReturnValue({ values: mockValues }); + + const result = await repository.save(transaction); + + expect(result.value).toBe(123.45); + }); + }); + + describe('findByExternalId', () => { + it('should find and return a transaction by external ID', async () => { + const dbResult = { + id: 1, + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: '750.25', + transactionStatus: 'approved', + createdAt: new Date('2024-01-01T00:00:00Z'), + }; + + const mockLimit = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findByExternalId('txn-123'); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockFrom).toHaveBeenCalled(); + expect(mockWhere).toHaveBeenCalled(); + expect(mockLimit).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(Transaction); + expect(result?.transactionExternalId).toBe('txn-123'); + expect(result?.transactionStatus).toBe('approved'); + expect(result?.value).toBe(750.25); + }); + + it('should return null when transaction not found', async () => { + const mockLimit = jest.fn().mockResolvedValue([]); + const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findByExternalId('non-existent-id'); + + expect(result).toBeNull(); + }); + + it('should parse string value to float', async () => { + const dbResult = { + transactionExternalId: 'txn-999', + accountExternalIdDebit: 'debit-account', + accountExternalIdCredit: 'credit-account', + transferTypeId: 3, + value: '999.99', + transactionStatus: 'pending', + createdAt: new Date(), + }; + + const mockLimit = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findByExternalId('txn-999'); + + expect(result?.value).toBe(999.99); + expect(typeof result?.value).toBe('number'); + }); + }); + + describe('update', () => { + it('should update transaction status and return updated entity', async () => { + const transaction = new Transaction( + 'txn-123', + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.APPROVED, + new Date('2024-01-01T00:00:00Z'), + ); + + const dbResult = { + transactionExternalId: 'txn-123', + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + transferTypeId: 1, + value: '500', + transactionStatus: 'approved', + createdAt: new Date('2024-01-01T00:00:00Z'), + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ returning: mockReturning }); + const mockSet = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.update = jest.fn().mockReturnValue({ set: mockSet }); + + const result = await repository.update(transaction); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ + transactionStatus: 'approved', + }); + expect(mockWhere).toHaveBeenCalled(); + expect(result).toBeInstanceOf(Transaction); + expect(result.transactionStatus).toBe('approved'); + }); + + it('should update to REJECTED status', async () => { + const transaction = new Transaction( + 'txn-456', + 'debit-account', + 'credit-account', + 2, + 1500, + TransactionStatus.REJECTED, + new Date(), + ); + + const dbResult = { + transactionExternalId: 'txn-456', + accountExternalIdDebit: 'debit-account', + accountExternalIdCredit: 'credit-account', + transferTypeId: 2, + value: '1500', + transactionStatus: 'rejected', + createdAt: new Date(), + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ returning: mockReturning }); + const mockSet = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.update = jest.fn().mockReturnValue({ set: mockSet }); + + const result = await repository.update(transaction); + + expect(mockSet).toHaveBeenCalledWith({ + transactionStatus: 'rejected', + }); + expect(result.transactionStatus).toBe('rejected'); + }); + + it('should preserve other fields during update', async () => { + const originalDate = new Date('2024-01-01T00:00:00Z'); + const transaction = new Transaction( + 'txn-789', + 'debit-original', + 'credit-original', + 1, + 250.75, + TransactionStatus.APPROVED, + originalDate, + ); + + const dbResult = { + transactionExternalId: 'txn-789', + accountExternalIdDebit: 'debit-original', + accountExternalIdCredit: 'credit-original', + transferTypeId: 1, + value: '250.75', + transactionStatus: 'approved', + createdAt: originalDate, + }; + + const mockReturning = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ returning: mockReturning }); + const mockSet = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.update = jest.fn().mockReturnValue({ set: mockSet }); + + const result = await repository.update(transaction); + + expect(result.transactionExternalId).toBe('txn-789'); + expect(result.accountExternalIdDebit).toBe('debit-original'); + expect(result.accountExternalIdCredit).toBe('credit-original'); + expect(result.value).toBe(250.75); + expect(result.createdAt).toEqual(originalDate); + }); + }); + + describe('mapToDomain', () => { + it('should map all transaction statuses correctly', async () => { + const statuses = [TransactionStatus.PENDING, TransactionStatus.APPROVED, TransactionStatus.REJECTED]; + + for (const status of statuses) { + const dbResult = { + transactionExternalId: 'txn-test', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: 1, + value: '100', + transactionStatus: status, + createdAt: new Date(), + }; + + const mockLimit = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findByExternalId('txn-test'); + + expect(result?.transactionStatus).toBe(status); + } + }); + + it('should handle all transfer type IDs', async () => { + const transferTypes = [1, 2, 3]; + + for (const typeId of transferTypes) { + const dbResult = { + transactionExternalId: 'txn-test', + accountExternalIdDebit: 'debit', + accountExternalIdCredit: 'credit', + transferTypeId: typeId, + value: '100', + transactionStatus: 'pending', + createdAt: new Date(), + }; + + const mockLimit = jest.fn().mockResolvedValue([dbResult]); + const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select = jest.fn().mockReturnValue({ from: mockFrom }); + + const result = await repository.findByExternalId('txn-test'); + + expect(result?.transferTypeId).toBe(typeId); + } + }); + }); +}); diff --git a/apps/transaction-service/src/infrastructure/repositories/transaction.repository.ts b/apps/transaction-service/src/infrastructure/repositories/transaction.repository.ts new file mode 100644 index 0000000000..44230bb449 --- /dev/null +++ b/apps/transaction-service/src/infrastructure/repositories/transaction.repository.ts @@ -0,0 +1,70 @@ +import { DRIZZLE_CLIENT, type DrizzleClient } from '@app/common'; +import { Transaction } from '@domain/entities/transaction.entity'; +import type { ITransactionRepository } from '@domain/repositories/transaction.repository.interface'; +import type { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { transactions } from '../database/schema'; + +@Injectable() +export class TransactionRepository implements ITransactionRepository { + constructor( + @Inject(DRIZZLE_CLIENT) + private readonly db: DrizzleClient, + ) {} + + async save(transaction: Transaction): Promise { + const [result] = await this.db + .insert(transactions) + .values({ + transactionExternalId: transaction.transactionExternalId, + accountExternalIdDebit: transaction.accountExternalIdDebit, + accountExternalIdCredit: transaction.accountExternalIdCredit, + transferTypeId: transaction.transferTypeId, + value: transaction.value.toString(), + transactionStatus: transaction.transactionStatus, + createdAt: transaction.createdAt, + }) + .returning(); + + return this.mapToDomain(result); + } + + async findByExternalId(externalId: string): Promise { + const [result] = await this.db + .select() + .from(transactions) + .where(eq(transactions.transactionExternalId, externalId)) + .limit(1); + + if (!result) { + return null; + } + + return this.mapToDomain(result); + } + + async update(transaction: Transaction): Promise { + const [result] = await this.db + .update(transactions) + .set({ + transactionStatus: transaction.transactionStatus, + }) + .where(eq(transactions.transactionExternalId, transaction.transactionExternalId)) + .returning(); + + return this.mapToDomain(result); + } + + private mapToDomain(data: any): Transaction { + return new Transaction( + data.transactionExternalId, + data.accountExternalIdDebit, + data.accountExternalIdCredit, + data.transferTypeId, + parseFloat(data.value), + data.transactionStatus as TransactionStatus, + data.createdAt, + ); + } +} diff --git a/apps/transaction-service/src/main.ts b/apps/transaction-service/src/main.ts new file mode 100644 index 0000000000..7163fb8723 --- /dev/null +++ b/apps/transaction-service/src/main.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import './config/instrument'; +import { CorrelationIdInterceptor } from '@app/common'; +import { HttpLoggerInterceptor, PinoLoggerService, shutdownTelemetry } from '@app/observability'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { TransactionModule } from './transaction.module'; + +async function bootstrap() { + const app = await NestFactory.create(TransactionModule, { + bufferLogs: true, + }); + + const logger = app.get(PinoLoggerService); + app.useLogger(logger); + app.useGlobalInterceptors(new CorrelationIdInterceptor()); + app.useGlobalInterceptors(new HttpLoggerInterceptor(logger)); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.enableCors(); + const port = process.env.PORT || 3000; + await app.listen(port); + logger.log(`🚀 Transaction Service running on http://localhost:${port}`, { + port, + environment: process.env.NODE_ENV, + }); + process.on('SIGTERM', async () => { + logger.log('SIGTERM signal received: closing HTTP server'); + await app.close(); + await shutdownTelemetry(); + process.exit(0); + }); + process.on('SIGINT', async () => { + logger.log('SIGINT signal received: closing HTTP server'); + await app.close(); + await shutdownTelemetry(); + process.exit(0); + }); +} + +bootstrap(); diff --git a/apps/transaction-service/src/presentation/controllers/transaction.controller.spec.ts b/apps/transaction-service/src/presentation/controllers/transaction.controller.spec.ts new file mode 100644 index 0000000000..a165c1885e --- /dev/null +++ b/apps/transaction-service/src/presentation/controllers/transaction.controller.spec.ts @@ -0,0 +1,364 @@ +import type { LoggerService } from '@app/observability'; +import { Transaction } from '@domain/entities/transaction.entity'; +import { TransactionStatus } from '@domain/value-objects/transaction-status.vo'; +import { NotFoundException } from '@nestjs/common'; +import type { Request } from 'express'; +import type { ICreateTransactionUseCase } from '../../domain/ports/input/create-transaction.use-case.interface'; +import type { IGetTransactionUseCase } from '../../domain/ports/input/get-transaction.use-case.interface'; +import type { CreateTransactionDto } from '../dtos/create-transaction.dto'; +import { TransactionController } from './transaction.controller'; + +describe('TransactionController', () => { + let controller: TransactionController; + let mockCreateUseCase: jest.Mocked; + let mockGetUseCase: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockCreateUseCase = { + execute: jest.fn(), + } as jest.Mocked; + + mockGetUseCase = { + execute: jest.fn(), + } as jest.Mocked; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + } as unknown as jest.Mocked; + + controller = new TransactionController(mockCreateUseCase, mockGetUseCase, mockLogger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createTransaction', () => { + const createDto: CreateTransactionDto = { + accountExternalIdDebit: 'debit-account-123', + accountExternalIdCredit: 'credit-account-456', + tranferTypeId: 1, + value: 500, + }; + + const mockRequest = { + correlationId: 'correlation-123', + requestId: 'request-456', + ip: '127.0.0.1', + headers: { + 'user-agent': 'Jest Test Agent', + }, + } as any as Request; + + const mockTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + createDto.tranferTypeId, + createDto.value, + TransactionStatus.PENDING, + new Date('2024-01-01T00:00:00Z'), + ); + + it('should create a transaction and return response DTO', async () => { + mockCreateUseCase.execute.mockResolvedValue(mockTransaction); + + const result = await controller.createTransaction(createDto, mockRequest); + + expect(result).toEqual({ + transactionExternalId: '123e4567-e89b-12d3-a456-426614174000', + transactionType: { name: 'Transfer' }, + transactionStatus: { name: 'pending' }, + value: 500, + createdAt: mockTransaction.createdAt, + }); + }); + + it('should extract context from request', async () => { + mockCreateUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.createTransaction(createDto, mockRequest); + + expect(mockCreateUseCase.execute).toHaveBeenCalledWith(createDto, { + correlationId: 'correlation-123', + requestId: 'request-456', + ipAddress: '127.0.0.1', + userAgent: 'Jest Test Agent', + }); + }); + + it('should log transaction creation', async () => { + mockCreateUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.createTransaction(createDto, mockRequest); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'Creating transaction', + expect.objectContaining({ + correlationId: 'correlation-123', + requestId: 'request-456', + }), + { + accountDebit: createDto.accountExternalIdDebit, + accountCredit: createDto.accountExternalIdCredit, + value: createDto.value, + }, + ); + }); + + it('should log successful transaction creation', async () => { + mockCreateUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.createTransaction(createDto, mockRequest); + + expect(mockLogger.log).toHaveBeenCalledWith('Transaction created successfully', expect.any(Object), { + transactionId: '123e4567-e89b-12d3-a456-426614174000', + }); + }); + + it('should map transfer type 1 to Transfer', async () => { + const transferTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + createDto.accountExternalIdDebit, + createDto.accountExternalIdCredit, + 1, + createDto.value, + TransactionStatus.PENDING, + new Date(), + ); + mockCreateUseCase.execute.mockResolvedValue(transferTransaction); + + const result = await controller.createTransaction(createDto, mockRequest); + + expect(result.transactionType.name).toBe('Transfer'); + }); + + it('should map transfer type 2 to Payment', async () => { + const paymentDto = { ...createDto, tranferTypeId: 2 }; + const paymentTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + paymentDto.accountExternalIdDebit, + paymentDto.accountExternalIdCredit, + 2, + paymentDto.value, + TransactionStatus.PENDING, + new Date(), + ); + mockCreateUseCase.execute.mockResolvedValue(paymentTransaction); + + const result = await controller.createTransaction(paymentDto, mockRequest); + + expect(result.transactionType.name).toBe('Payment'); + }); + + it('should map transfer type 3 to Withdrawal', async () => { + const withdrawalDto = { ...createDto, tranferTypeId: 3 }; + const withdrawalTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + withdrawalDto.accountExternalIdDebit, + withdrawalDto.accountExternalIdCredit, + 3, + withdrawalDto.value, + TransactionStatus.PENDING, + new Date(), + ); + mockCreateUseCase.execute.mockResolvedValue(withdrawalTransaction); + + const result = await controller.createTransaction(withdrawalDto, mockRequest); + + expect(result.transactionType.name).toBe('Withdrawal'); + }); + + it('should handle unknown transfer types', async () => { + const unknownDto = { ...createDto, tranferTypeId: 999 }; + const unknownTransaction = new Transaction( + '123e4567-e89b-12d3-a456-426614174000', + unknownDto.accountExternalIdDebit, + unknownDto.accountExternalIdCredit, + 999, + unknownDto.value, + TransactionStatus.PENDING, + new Date(), + ); + mockCreateUseCase.execute.mockResolvedValue(unknownTransaction); + + const result = await controller.createTransaction(unknownDto, mockRequest); + + expect(result.transactionType.name).toBe('Unknown'); + }); + + it('should handle requests without correlationId', async () => { + const requestWithoutIds = { + ip: '127.0.0.1', + headers: { 'user-agent': 'Test' }, + } as any as Request; + + mockCreateUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.createTransaction(createDto, requestWithoutIds); + + expect(mockCreateUseCase.execute).toHaveBeenCalledWith(createDto, { + correlationId: undefined, + requestId: undefined, + ipAddress: '127.0.0.1', + userAgent: 'Test', + }); + }); + + it('should propagate use case errors', async () => { + const error = new Error('Use case error'); + mockCreateUseCase.execute.mockRejectedValue(error); + + await expect(controller.createTransaction(createDto, mockRequest)).rejects.toThrow('Use case error'); + }); + }); + + describe('getTransaction', () => { + const transactionId = '123e4567-e89b-12d3-a456-426614174000'; + + const mockRequest = { + correlationId: 'correlation-123', + requestId: 'request-456', + ip: '127.0.0.1', + headers: { + 'user-agent': 'Jest Test Agent', + }, + } as any as Request; + + const mockTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.APPROVED, + new Date('2024-01-01T00:00:00Z'), + ); + + it('should get transaction and return response DTO', async () => { + mockGetUseCase.execute.mockResolvedValue(mockTransaction); + + const result = await controller.getTransaction(transactionId, mockRequest); + + expect(result).toEqual({ + transactionExternalId: transactionId, + transactionType: { name: 'Transfer' }, + transactionStatus: { name: 'approved' }, + value: 500, + createdAt: mockTransaction.createdAt, + }); + }); + + it('should call use case with transaction ID', async () => { + mockGetUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.getTransaction(transactionId, mockRequest); + + expect(mockGetUseCase.execute).toHaveBeenCalledWith(transactionId); + }); + + it('should log transaction fetch', async () => { + mockGetUseCase.execute.mockResolvedValue(mockTransaction); + + await controller.getTransaction(transactionId, mockRequest); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'Fetching transaction', + expect.objectContaining({ + correlationId: 'correlation-123', + requestId: 'request-456', + }), + { + transactionId, + }, + ); + }); + + it('should map PENDING status correctly', async () => { + const pendingTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.PENDING, + new Date(), + ); + mockGetUseCase.execute.mockResolvedValue(pendingTransaction); + + const result = await controller.getTransaction(transactionId, mockRequest); + + expect(result.transactionStatus.name).toBe('pending'); + }); + + it('should map REJECTED status correctly', async () => { + const rejectedTransaction = new Transaction( + transactionId, + 'debit-account-123', + 'credit-account-456', + 1, + 500, + TransactionStatus.REJECTED, + new Date(), + ); + mockGetUseCase.execute.mockResolvedValue(rejectedTransaction); + + const result = await controller.getTransaction(transactionId, mockRequest); + + expect(result.transactionStatus.name).toBe('rejected'); + }); + + it('should propagate NotFoundException from use case', async () => { + mockGetUseCase.execute.mockRejectedValue(new NotFoundException('Transaction not found')); + + await expect(controller.getTransaction(transactionId, mockRequest)).rejects.toThrow(NotFoundException); + }); + + it('should propagate use case errors', async () => { + const error = new Error('Database error'); + mockGetUseCase.execute.mockRejectedValue(error); + + await expect(controller.getTransaction(transactionId, mockRequest)).rejects.toThrow('Database error'); + }); + }); + + describe('mapToResponse (private method)', () => { + it('should map all transaction fields correctly', async () => { + const transaction = new Transaction( + 'txn-123', + 'debit-123', + 'credit-456', + 2, + 1000, + TransactionStatus.APPROVED, + new Date('2024-06-15T12:00:00Z'), + ); + + mockGetUseCase.execute.mockResolvedValue(transaction); + + const mockReq = { + correlationId: 'correlation-123', + requestId: 'request-456', + ip: '127.0.0.1', + headers: { + 'user-agent': 'Jest Test Agent', + }, + } as any as Request; + + const result = await controller.getTransaction('txn-123', mockReq); + + expect(result).toEqual({ + transactionExternalId: 'txn-123', + transactionType: { name: 'Payment' }, + transactionStatus: { name: 'approved' }, + value: 1000, + createdAt: transaction.createdAt, + }); + }); + }); +}); diff --git a/apps/transaction-service/src/presentation/controllers/transaction.controller.ts b/apps/transaction-service/src/presentation/controllers/transaction.controller.ts new file mode 100644 index 0000000000..5f0130d5cc --- /dev/null +++ b/apps/transaction-service/src/presentation/controllers/transaction.controller.ts @@ -0,0 +1,85 @@ +import { type TransactionType, TransactionTypeNames } from '@app/common'; +import { LoggerService } from '@app/observability'; +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Param, Post, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { + CREATE_TRANSACTION_USE_CASE, + type ICreateTransactionUseCase, +} from '../../domain/ports/input/create-transaction.use-case.interface'; +import { + GET_TRANSACTION_USE_CASE, + type IGetTransactionUseCase, +} from '../../domain/ports/input/get-transaction.use-case.interface'; +import { CreateTransactionDto } from '../dtos/create-transaction.dto'; +import type { TransactionResponseDto } from '../dtos/transaction-response.dto'; + +@Controller('transactions') +export class TransactionController { + constructor( + @Inject(CREATE_TRANSACTION_USE_CASE) + private readonly createTransactionUseCase: ICreateTransactionUseCase, + @Inject(GET_TRANSACTION_USE_CASE) + private readonly getTransactionUseCase: IGetTransactionUseCase, + private readonly logger: LoggerService, + ) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + async createTransaction(@Body() dto: CreateTransactionDto, @Req() req: Request): Promise { + const context = { + correlationId: (req as any).correlationId, + requestId: (req as any).requestId, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }; + + this.logger.log('Creating transaction', context, { + accountDebit: dto.accountExternalIdDebit, + accountCredit: dto.accountExternalIdCredit, + value: dto.value, + }); + + const transaction = await this.createTransactionUseCase.execute(dto, context); + + this.logger.log('Transaction created successfully', context, { + transactionId: transaction.transactionExternalId, + }); + + return this.mapToResponse(transaction); + } + + @Get(':transactionExternalId') + async getTransaction( + @Param('transactionExternalId') transactionExternalId: string, + @Req() req: Request, + ): Promise { + const context = { + correlationId: (req as any).correlationId, + requestId: (req as any).requestId, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }; + + this.logger.log('Fetching transaction', context, { + transactionId: transactionExternalId, + }); + + const transaction = await this.getTransactionUseCase.execute(transactionExternalId); + + return this.mapToResponse(transaction); + } + + private mapToResponse(transaction: any): TransactionResponseDto { + return { + transactionExternalId: transaction.transactionExternalId, + transactionType: { + name: TransactionTypeNames[transaction.transferTypeId as TransactionType] || 'Unknown', + }, + transactionStatus: { + name: transaction.transactionStatus, + }, + value: transaction.value, + createdAt: transaction.createdAt, + }; + } +} diff --git a/apps/transaction-service/src/presentation/dtos/create-transaction.dto.ts b/apps/transaction-service/src/presentation/dtos/create-transaction.dto.ts new file mode 100644 index 0000000000..b2478ae717 --- /dev/null +++ b/apps/transaction-service/src/presentation/dtos/create-transaction.dto.ts @@ -0,0 +1,18 @@ +import { TransactionType } from '@app/common'; +import { IsEnum, IsNumber, IsUUID, Min } from 'class-validator'; + +export class CreateTransactionDto { + @IsUUID() + accountExternalIdDebit: string; + + @IsUUID() + accountExternalIdCredit: string; + + @IsNumber() + @IsEnum(TransactionType) + tranferTypeId: number; + + @IsNumber() + @Min(0) + value: number; +} diff --git a/apps/transaction-service/src/presentation/dtos/transaction-response.dto.ts b/apps/transaction-service/src/presentation/dtos/transaction-response.dto.ts new file mode 100644 index 0000000000..19f5a53c02 --- /dev/null +++ b/apps/transaction-service/src/presentation/dtos/transaction-response.dto.ts @@ -0,0 +1,11 @@ +export class TransactionResponseDto { + transactionExternalId: string; + transactionType: { + name: string; + }; + transactionStatus: { + name: string; + }; + value: number; + createdAt: Date; +} diff --git a/apps/transaction-service/src/transaction.module.ts b/apps/transaction-service/src/transaction.module.ts new file mode 100644 index 0000000000..58e496dbec --- /dev/null +++ b/apps/transaction-service/src/transaction.module.ts @@ -0,0 +1,98 @@ +import { DatabaseModule, KafkaModule, RedisModule } from '@app/common'; +import { LoggingModule, ObservabilityModule, TracingModule } from '@app/observability'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { CreateTransactionUseCase } from './application/use-cases/create-transaction.use-case'; +import { GetTransactionUseCase } from './application/use-cases/get-transaction.use-case'; +import { UpdateTransactionStatusUseCase } from './application/use-cases/update-transaction-status.use-case'; +import { CREATE_TRANSACTION_USE_CASE } from './domain/ports/input/create-transaction.use-case.interface'; +import { GET_TRANSACTION_USE_CASE } from './domain/ports/input/get-transaction.use-case.interface'; +import { UPDATE_TRANSACTION_STATUS_USE_CASE } from './domain/ports/input/update-transaction-status.use-case.interface'; +import { EVENT_PUBLISHER } from './domain/ports/output/event-publisher.interface'; +import { TRANSACTION_REPOSITORY } from './domain/repositories/transaction.repository.interface'; +import { TransactionEventService } from './infrastructure/messaging/transaction-event.service'; +import { TransactionStatusConsumer } from './infrastructure/messaging/transaction-status.consumer'; +import { TransactionRepository } from './infrastructure/repositories/transaction.repository'; +import { TransactionController } from './presentation/controllers/transaction.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule.forRootAsync({ + isGlobal: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const url = configService.get('TRANSACTION_DB_URL'); + if (!url) { + throw new Error('TRANSACTION_DB_URL environment variable is required'); + } + return { url }; + }, + }), + RedisModule, + ObservabilityModule.forRoot({ + serviceName: 'transaction-service', + enableSentry: process.env.SENTRY_ENABLED === 'true', + sentryDsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV || 'development', + }), + TracingModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + serviceName: 'transaction-service', + serviceVersion: '1.0.0', + otlpEndpoint: config.get('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), + environment: config.get('NODE_ENV', 'development'), + sampling: Number.parseFloat(config.get('OTEL_SAMPLING_RATIO', '1.0')), + enabled: config.get('OTEL_ENABLED', 'true') !== 'false', + }), + }), + + LoggingModule.forRoot('transaction-service'), + KafkaModule.forRootAsync({ + isGlobal: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const kafkaBroker = configService.get('KAFKA_BROKER'); + if (!kafkaBroker) { + throw new Error('KAFKA_BROKER environment variable is required'); + } + return { + clientId: 'transaction-service', + brokers: [kafkaBroker], + consumerConfig: { + groupId: 'transaction-service-group', + }, + }; + }, + }), + ], + controllers: [TransactionController], + providers: [ + { + provide: CREATE_TRANSACTION_USE_CASE, + useClass: CreateTransactionUseCase, + }, + { + provide: GET_TRANSACTION_USE_CASE, + useClass: GetTransactionUseCase, + }, + { + provide: UPDATE_TRANSACTION_STATUS_USE_CASE, + useClass: UpdateTransactionStatusUseCase, + }, + { + provide: TRANSACTION_REPOSITORY, + useClass: TransactionRepository, + }, + { + provide: EVENT_PUBLISHER, + useClass: TransactionEventService, + }, + TransactionStatusConsumer, + ], +}) +export class TransactionModule {} diff --git a/apps/transaction-service/tsconfig.app.json b/apps/transaction-service/tsconfig.app.json new file mode 100644 index 0000000000..b29b969386 --- /dev/null +++ b/apps/transaction-service/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/transaction-service" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000000..fb2c590207 --- /dev/null +++ b/biome.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["apps/**", "libs/**", "*.ts", "*.js", "*.json"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noStaticOnlyClass": "off" + }, + "correctness": { + "noUnusedVariables": "warn" + }, + "suspicious": { + "noExplicitAny": "off", + "noDebugger": "warn" + }, + "style": { + "useConst": "warn", + "useImportType": "off" + } + } + }, + "javascript": { + "parser": { + "unsafeParameterDecoratorsEnabled": true + }, + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto" + } + }, + "json": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/config/database/drizzle.config.ts b/config/database/drizzle.config.ts new file mode 100644 index 0000000000..40b016bc4d --- /dev/null +++ b/config/database/drizzle.config.ts @@ -0,0 +1,13 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: [ + './apps/transaction-service/src/infrastructure/database/schema.ts', + './apps/anti-fraud-service/src/infrastructure/database/fraud-rules.schema.ts', + ], + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.TRANSACTION_DB_URL || process.env.ANTIFRAUD_DB_URL || '', + }, +} satisfies Config; diff --git a/config/jest.config.js b/config/jest.config.js new file mode 100644 index 0000000000..3269fe9d94 --- /dev/null +++ b/config/jest.config.js @@ -0,0 +1,51 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '..', + testRegex: String.raw`.*\.spec\.ts$`, + transform: { + '^.+\\.(t|j)s$': ['ts-jest', { + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }], + }, + maxWorkers: 2, + workerIdleMemoryLimit: '512MB', + collectCoverageFrom: [ + 'apps/**/*.ts', + 'libs/**/*.ts', + '!apps/**/*.module.ts', + '!libs/**/*.module.ts', + '!apps/**/main.ts', + '!apps/**/config/instrument.ts', + '!apps/**/migrations/**', + '!apps/**/dto/**', + '!apps/**/dtos/**', + '!libs/**/index.ts', + '!**/*.config.ts', + '!**/*.d.ts', + '!**/*.spec.ts', + '!**/node_modules/**', + '!apps/**/scripts/**', + '!libs/observability/**', + '!libs/common/src/exceptions/**', + '!libs/common/src/interceptors/**', + '!apps/**/database/schema.ts', + '!apps/**/database/*.schema.ts', + ], + coverageDirectory: './coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@app/common$': '/libs/common/src', + '^@app/common/(.*)$': '/libs/common/src/$1', + '^@app/observability$': '/libs/observability/src', + '^@app/observability/(.*)$': '/libs/observability/src/$1', + '^@domain/(.*)$': '/apps/transaction-service/src/domain/$1', + '^@application/(.*)$': '/apps/transaction-service/src/application/$1', + '^@infrastructure/(.*)$': '/apps/transaction-service/src/infrastructure/$1', + '^@presentation/(.*)$': '/apps/transaction-service/src/presentation/$1', + }, + setupFilesAfterEnv: ['/test/setup.ts'], + testTimeout: 10000, +}; diff --git a/docker-compose.yml b/docker-compose.yml index 0e8807f21c..e9594f4154 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,42 @@ -version: "3.7" services: postgres: image: postgres:14 + container_name: postgres ports: - "5432:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + zookeeper: image: confluentinc/cp-zookeeper:5.5.3 + container_name: zookeeper environment: ZOOKEEPER_CLIENT_PORT: 2181 + healthcheck: + test: ["CMD-SHELL", "echo srvr | nc localhost 2181 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - yape-network + kafka: - image: confluentinc/cp-enterprise-kafka:5.5.3 - depends_on: [zookeeper] + image: confluentinc/cp-kafka:5.5.3 + container_name: kafka + depends_on: + zookeeper: + condition: service_healthy environment: KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 @@ -21,5 +44,178 @@ services: KAFKA_BROKER_ID: 1 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_JMX_PORT: 9991 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ports: - - 9092:9092 + - "9092:9092" + healthcheck: + test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"] + interval: 10s + timeout: 10s + retries: 5 + networks: + - yape-network + + db-migration: + build: + context: . + dockerfile: apps/transaction-service/Dockerfile + target: builder + container_name: db-migration + environment: + - TRANSACTION_DB_URL=${TRANSACTION_DB_URL:-postgresql://postgres:postgres@postgres:5432/postgres} + - ANTIFRAUD_DB_URL=${ANTIFRAUD_DB_URL:-postgresql://postgres:postgres@postgres:5432/postgres} + depends_on: + postgres: + condition: service_healthy + command: sh -c "npm run db:migrate && npm run seed:fraud-rules" + networks: + - yape-network + + transaction-service: + build: + context: . + dockerfile: apps/transaction-service/Dockerfile + target: runner + container_name: transaction-service + ports: + - "3000:3000" + environment: + # Database + - TRANSACTION_DB_URL=${TRANSACTION_DB_URL:-postgresql://postgres:postgres@postgres:5432/postgres} + # Kafka + - KAFKA_BROKER=${KAFKA_BROKER:-kafka:29092} + # Redis + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + # Service + - PORT=3000 + - SERVICE_NAME=transaction-service + - NODE_ENV=${NODE_ENV:-development} + # OpenTelemetry + - OTEL_ENABLED=${OTEL_ENABLED:-true} + - OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318 + - OTEL_SAMPLING_RATIO=${OTEL_SAMPLING_RATIO:-1.0} + # Logging + - LOG_LEVEL=${LOG_LEVEL:-info} + # Sentry (optional) + - SENTRY_ENABLED=${SENTRY_ENABLED:-false} + depends_on: + db-migration: + condition: service_completed_successfully + postgres: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - yape-network + restart: unless-stopped + + anti-fraud-service: + build: + context: . + dockerfile: apps/anti-fraud-service/Dockerfile + target: runner + container_name: anti-fraud-service + ports: + - "3001:3001" + environment: + # Database + - ANTIFRAUD_DB_URL=${ANTIFRAUD_DB_URL:-postgresql://postgres:postgres@postgres:5432/postgres} + # Kafka + - KAFKA_BROKER=${KAFKA_BROKER:-kafka:29092} + # Redis + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + # Service + - ANTI_FRAUD_PORT=3001 + - SERVICE_NAME=anti-fraud-service + - NODE_ENV=${NODE_ENV:-development} + # OpenTelemetry + - OTEL_ENABLED=${OTEL_ENABLED:-true} + - OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318 + - OTEL_SAMPLING_RATIO=${OTEL_SAMPLING_RATIO:-1.0} + # Logging + - LOG_LEVEL=${LOG_LEVEL:-info} + # Sentry (optional) + - SENTRY_ENABLED=${SENTRY_ENABLED:-false} + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - yape-network + restart: unless-stopped + + kafka-ui: + image: provectuslabs/kafka-ui:v0.7.2 + container_name: kafka-ui + ports: + - "9000:8080" + environment: + - KAFKA_CLUSTERS_0_NAME=local + - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka:29092 + - KAFKA_CLUSTERS_0_ZOOKEEPER=zookeeper:2181 + depends_on: + kafka: + condition: service_healthy + networks: + - yape-network + restart: unless-stopped + + redis: + image: redis:alpine + container_name: redis + ports: + - "6379:6379" + networks: + - yape-network + restart: unless-stopped + + # ============================================================================ + # Observability Stack (Minimal: Tempo + Grafana for traces visualization) + # ============================================================================ + + # Grafana Tempo - Distributed Tracing Backend + tempo: + image: grafana/tempo:latest + container_name: tempo + command: [ "-config.file=/etc/tempo.yaml" ] + volumes: + - ./observability/tempo/tempo.yaml:/etc/tempo.yaml + - tempo-data:/tmp/tempo + ports: + - "3200:3200" # Tempo API + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + networks: + - yape-network + restart: unless-stopped + + # Grafana - Visualization Dashboard + grafana: + image: grafana/grafana:latest + container_name: grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor + volumes: + - ./observability/grafana/provisioning:/etc/grafana/provisioning + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards + - grafana-data:/var/lib/grafana + ports: + - "3002:3000" + networks: + - yape-network + restart: unless-stopped + depends_on: + - tempo + +networks: + yape-network: + driver: bridge + +volumes: + tempo-data: + grafana-data: + postgres-data: diff --git a/docs/API-TESTING.md b/docs/API-TESTING.md new file mode 100644 index 0000000000..e2b017f631 --- /dev/null +++ b/docs/API-TESTING.md @@ -0,0 +1,101 @@ +# API Testing Guide + +## Overview + +Guía para probar las APIs del Transaction Service y Anti-Fraud Service. + +## Quick Start + +### 1. Iniciar Servicios + +```bash +# Opción A: Docker (Recomendado) +docker-compose up -d + +# Opción B: Local +npm run start:dev +``` + +### 2. Verificar Logs de Servicios + +```bash +docker logs transaction-service -f +docker logs anti-fraud-service -f +``` + +### 3. Ejecutar Test Automatizado + +```bash +npm run test:api +``` + +### 4. Postman + +También puedes importar la colección de Postman ubicada en: +`docs/collection/RETO YAPE.postman_collection.json` + +## Escenarios de Testing + +El script `test/api/test-transaction-api.sh` cubre: + +1. Verificación de logs del servicio +2. Crear transacción válida (< 1000) +3. Consultar transacción por ID +4. Crear transacción de alto valor (> 1000) para verificar anti-fraude + +## Reglas de Anti-Fraude + +| Valor de Transacción | Status Esperado | Acción | +|----------------------|----------------|---------| +| < 1000 | PENDING → APPROVED | Normal | +| 1001 - 5000 | PENDING → REJECTED | Alto riesgo | +| > 5000 | PENDING → REVIEW | Muy alto - requiere revisión (Regla desactivada por defecto) | + +## Pruebas Manuales con curl + +### Crear Transacción + +```bash +curl -X POST http://localhost:3000/transactions \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "550e8400-e29b-41d4-a716-446655440000", + "accountExternalIdCredit": "550e8400-e29b-41d4-a716-446655440001", + "tranferTypeId": 1, + "value": 500 + }' +``` + +### Obtener Transacción + +```bash +curl http://localhost:3000/transactions/{transaction-id} +``` + +### Debugging + +```bash +# Ver logs +docker logs transaction-service -f +docker logs anti-fraud-service -f + +# Curl verbose (Verificar cabeceras) +curl -v http://localhost:3000/transactions/{transaction-id} +``` + +## Troubleshooting + +### Servicios no responden +```bash +# Verificar estado +docker ps + +# Reiniciar servicios +docker-compose restart +``` + +### Ver logs de errores +```bash +docker logs transaction-service +docker logs anti-fraud-service +``` diff --git a/docs/FRAUD_RULES.md b/docs/FRAUD_RULES.md new file mode 100644 index 0000000000..69cc1188fd --- /dev/null +++ b/docs/FRAUD_RULES.md @@ -0,0 +1,232 @@ +# 🛡️ Sistema de Reglas Anti-Fraude + +Sistema configurable para validar transacciones dinámicamente sin código hardcodeado. + +## 🎯 Características Principales + +- **Regla Obligatoria**: `AMOUNT_THRESHOLD` (Única requerida por el reto). +- **Extensibilidad**: Se incluyen otros tipos de reglas únicamente de forma **demostrativa**. +- **Configurabilidad**: Aunque el reto pedía un monto fijo (>1000), se implementó de forma configurable para permitir flexibilidad futura. +- **Sistema de prioridades**: Para resolución de conflictos entre múltiples reglas. +- **Gestión vía base de datos**: Sin necesidad de cambios en el código para ajustar umbrales. +- **Auditoría completa**: Registro de todas las evaluaciones realizadas. + +## 🔧 Tipos de Reglas + +| Tipo | Descripción | Importancia | Ejemplo de Condición | +|------|-------------|------------|----------------------| +| `AMOUNT_THRESHOLD` | **Umbral de monto** | ⭐ **REQUERIDA** | `{"operator": "gt", "value": 1000}` | +| `DAILY_LIMIT` | Límite diario | 🧪 Demo | `{"maxAmount": 5000, "maxTransactions": 10}` | +| `VELOCITY_CHECK` | Detección de velocidad | 🧪 Demo | `{"maxTransactions": 5, "timeWindowMinutes": 30}` | +| `ACCOUNT_BLACKLIST` | Lista negra | 🧪 Demo | `{"accounts": ["uuid1", "uuid2"]}` | +| `TRANSFER_TYPE_LIMIT` | Límite por tipo | 🧪 Demo | `{"transferTypeId": 2, "maxAmount": 500}` | +| `TIME_BASED` | Restricción horaria | 🧪 Demo | `{"allowedHours": {"start": 8, "end": 18}}` | + +## 🎬 Acciones + +- **`REJECT`**: Rechaza inmediatamente (prioridad máxima) +- **`REVIEW`**: Marca para revisión manual +- **`FLAG`**: Solo marca sin bloquear +- **`APPROVE`**: Aprueba (prioridad mínima) + +## 📊 Esquema de Base de Datos + +### fraud_rules +```sql +CREATE TABLE fraud_rules ( + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + rule_type VARCHAR(50) NOT NULL, + condition JSONB NOT NULL, + action VARCHAR(20) NOT NULL DEFAULT 'REJECT', + priority INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +### fraud_rule_executions +```sql +CREATE TABLE fraud_rule_executions ( + id UUID PRIMARY KEY, + rule_id UUID NOT NULL REFERENCES fraud_rules(id), + transaction_external_id UUID NOT NULL, + matched BOOLEAN NOT NULL, + action VARCHAR(20) NOT NULL, + details JSONB, + executed_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +## 🗄️ Gestión de Reglas + +### Gestión Actual +Las reglas de fraude se gestionan directamente a través de la base de datos. No hay APIs REST disponibles para gestión en tiempo real. + +### Métodos de Gestión +1. **Script de seeding**: `npm run seed:fraud-rules` +2. **Migraciones de base de datos**: Actualizaciones vía SQL/scripts +3. **Acceso directo a BD**: Consultas y modificaciones directas + +### Recomendación +Para entornos de producción, considere implementar APIs de gestión o una interfaz administrativa separada. + +## 🎯 Flujo de Evaluación + +```mermaid +sequenceDiagram + participant T as Transaction + participant AF as AntiFraudService + participant RE as RulesEngine + participant DB as Database + participant K as Kafka + + T->>AF: Transaction Created Event + AF->>RE: evaluateTransaction(context) + RE->>DB: Get Active Rules (ordered by priority) + + loop For each rule + RE->>RE: evaluateRule() + RE->>DB: Log execution + end + + RE->>RE: determineFinalAction() + RE-->>AF: {action, matchedRules, reasons} + + alt REJECT + AF->>K: Publish TRANSACTION_REJECTED + else APPROVE + AF->>K: Publish TRANSACTION_APPROVED + else REVIEW/FLAG + AF->>K: Publish TRANSACTION_REJECTED (or custom topic) + end +``` + +## 🔄 Sistema de Prioridades + +- **Menor número = Mayor prioridad** +- Las reglas se evalúan en orden de prioridad +- Todas las reglas se evalúan (no hay short-circuit) +- La acción final se determina con la siguiente prioridad: + 1. `REJECT` (prioridad máxima) + 2. `REVIEW` + 3. `FLAG` + 4. `APPROVE` (prioridad mínima) + +## 📝 Configuración Inicial + +### 1. Crear las tablas +```bash +npm run db:push +``` + +### 2. Seed de reglas por defecto +```bash +npm run seed:fraud-rules +``` + +Esto creará las siguientes reglas: + +1. **High Amount Transaction** (Activa) + - Rechaza transacciones > 1000 + - Prioridad: 100 + +2. **Very High Amount - Review** (Inactiva) + - Marca para revisión transacciones > 5000 + - Prioridad: 50 + +3. **Weekend Transfer Limit** (Inactiva) + - Rechaza transferencias en fin de semana + - Prioridad: 200 + +4. **Payment Type Limit** (Inactiva) + - Limita pagos (tipo 2) a 500 + - Prioridad: 150 + +## 💡 Ejemplos de Uso + +### Ejemplo 1: Cambiar el umbral de monto + +1. Consultar regla actual en base de datos: +```sql +SELECT id, name, condition FROM fraud_rules +WHERE rule_type = 'AMOUNT_THRESHOLD' AND is_active = true; +``` + +2. Actualizar la condición: +```sql +UPDATE fraud_rules +SET condition = '{"operator": "gt", "value": 2000}', + updated_at = NOW() +WHERE id = 'rule-id-here'; +``` + +### Ejemplo 2: Habilitar restricción de fin de semana + +```sql +UPDATE fraud_rules +SET is_active = true, + updated_at = NOW() +WHERE name LIKE '%weekend%'; +``` + +### Ejemplo 3: Crear nueva regla de blacklist + +```sql +INSERT INTO fraud_rules ( + id, name, description, rule_type, condition, action, priority, is_active +) VALUES ( + gen_random_uuid(), + 'Blocked Accounts', + 'Accounts reported for fraud', + 'ACCOUNT_BLACKLIST', + '{"accounts": ["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"]}', + 'REJECT', + 10, + true +); +``` + +## 🔍 Auditoría y Monitoreo + +### Ver qué reglas se activaron para una transacción + +Las ejecuciones se registran automáticamente en `fraud_rule_executions`: + +```sql +SELECT + fr.name, + fre.matched, + fre.action, + fre.details, + fre.executed_at +FROM fraud_rule_executions fre +JOIN fraud_rules fr ON fre.rule_id = fr.id +WHERE fre.transaction_external_id = '{transaction-id}' +ORDER BY fre.executed_at; +``` + +### Estadísticas de una regla + +```sql +SELECT + COUNT(*) as total_executions, + COUNT(CASE WHEN matched = true THEN 1 END) as matches, + COUNT(CASE WHEN matched = true AND action = 'REJECT' THEN 1 END) as rejections +FROM fraud_rule_executions +WHERE rule_id = 'rule-id-here' + AND executed_at >= NOW() - INTERVAL '30 days'; +``` + +## 🛠️ Extensión del Sistema + +Para agregar un nuevo tipo de regla: + +1. **Agregar al enum** en `fraud-rules.types.ts` +2. **Crear interfaz de condición** +3. **Implementar método evaluador** en `RulesEngineService` +4. **Agregar case al switch** + +**Nota**: Actualmente `DAILY_LIMIT` y `VELOCITY_CHECK` están marcados como "implementación pendiente" en el código. diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000000..9ce904eaf6 --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,225 @@ +# Guía de Arquitectura + +Documentación técnica profunda sobre la arquitectura hexagonal, decisiones de diseño y patrones implementados. + +> **Nota:** Para instalación y comandos básicos, ver [README.md](../README.md) + +--- + +## Arquitectura Hexagonal - Visión General + +### Principios Fundamentales + +La **Arquitectura Hexagonal** (también conocida como **Ports & Adapters**) separa la lógica de negocio de las preocupaciones externas, creando un sistema modular y testeable. + +#### 🎯 **Núcleo Independiente** +- **Domain Layer**: Reglas de negocio puras, independientes de frameworks +- **Application Layer**: Casos de uso que coordinan el flujo de la aplicación +- **Ports**: Interfaces que definen contratos para comunicación externa +- **Adapters**: Implementaciones concretas de las interfaces + +#### 🔄 **Capas de Adaptación** +- **Infrastructure Layer**: Adaptadores para bases de datos, mensajería, APIs externas +- **Presentation Layer**: Adaptadores para interfaces de usuario (REST APIs, GraphQL, etc.) + +### Beneficios Arquitectónicos + +| Beneficio | Descripción | +|-----------|-------------| +| **Testabilidad** | Lógica de dominio pura, fácil de testear unitariamente | +| **Mantenibilidad** | Cambios en infraestructura no afectan el dominio | +| **Flexibilidad** | Fácil cambiar tecnologías (DB, mensajería, frameworks) | +| **Escalabilidad** | Servicios independientes, deployment separado | +| **Evolución** | Nuevo comportamiento sin modificar código existente | + +--- + +## Domain-Driven Design (DDD) + +### Bounded Contexts + +El sistema está dividido en **dos bounded contexts principales**: + +#### 1. **Transaction Management** (Contexto Principal) +- **Responsabilidades**: Crear, consultar y gestionar el ciclo de vida de transacciones +- **Entidades**: `Transaction` con estados (PENDING → APPROVED/REJECTED) +- **Value Objects**: `TransactionStatus`, montos, IDs externos +- **Reglas de negocio**: Validaciones básicas, estado de transacciones + +#### 2. **Anti-Fraud Validation** (Contexto de Soporte) +- **Responsabilidades**: Evaluación de riesgo basada en reglas configurables +- **Entidades**: `FraudRule`, `FraudRuleExecution` +- **Motor de reglas**: Sistema extensible de validación +- **Auditoría**: Registro completo de todas las evaluaciones + +### Strategic Design Patterns + +- **Ubiquitous Language**: Terminología consistente (Transaction, FraudRule, etc.) +- **Context Mapping**: Comunicación clara entre bounded contexts vía eventos +- **Aggregate Design**: Entidades con límites claros de consistencia +- **Domain Events**: Comunicación interna dentro de bounded contexts + +--- + +## Event-Driven Architecture + +### Patrón de Comunicación Asíncrona + +El sistema utiliza **Event-Driven Architecture** para desacoplar servicios y mejorar la resiliencia: + +#### 🎯 **Ventajas del Approach** +- **Desacoplamiento**: Servicios no necesitan conocerse mutuamente +- **Escalabilidad**: Procesamiento asíncrono permite manejar picos de carga +- **Resiliencia**: Fallos en un servicio no afectan inmediatamente a otros +- **Evolución**: Nuevos consumidores pueden suscribirse sin modificar productores + +#### 📋 **Flujo de Eventos Principal** + +```mermaid +graph TD + A[Cliente] --> B[POST /transactions] + B --> C[Transaction Service] + C --> D[(DB: PENDING)] + C --> E[Kafka: TransactionCreated] + E --> F[Anti-Fraud Service] + F --> G[Rules Engine] + G --> H[(DB: Rules Log)] + G --> I{Kafka: Approved/Rejected} + I --> J[Transaction Service] + J --> K[(DB: Status Update)] +``` + +#### 🔄 **Eventos del Sistema** +- **`TransactionCreated`**: Nueva transacción requiere validación +- **`TransactionApproved`**: Validación exitosa, transacción aprobada +- **`TransactionRejected`**: Validación fallida, transacción rechazada + +### Decisiones de Diseño + +#### **Kafka como Message Broker** +- **Durabilidad**: Mensajes persistentes con replicas +- **Orden**: Garantía de orden por partición +- **Escalabilidad**: Consumer groups permiten múltiples instancias +- **Ecosistema**: Amplio soporte y herramientas (Kafka UI, Streams, etc.) + +#### **Event Sourcing Considerations** +- **Audit Trail**: Todos los cambios quedan registrados +- **Debugging**: Posibilidad de reconstruir estado desde eventos +- **Analytics**: Eventos pueden alimentar sistemas de BI + +--- + +## Estrategia de Persistencia + +### PostgreSQL como Base de Datos Principal + +#### **Decisiones de Diseño** +- **Relacional vs NoSQL**: PostgreSQL para consistencia y transacciones ACID +- **Type Safety**: Drizzle ORM para schemas type-safe en TypeScript +- **Migrations**: Schema definido como código, migraciones automáticas + +#### **Esquemas Principales** +- **Transacciones**: Entidad principal con estados y metadatos +- **Reglas de Fraude**: Configuración dinámica de reglas de validación +- **Auditoría**: Log completo de evaluaciones para compliance + +#### **Patrones de Acceso** +- **Repository Pattern**: Abstracción de acceso a datos +- **Transaction Management**: Operaciones atómicas en base de datos +- **Query Optimization**: Índices y estrategias de consulta eficientes + +--- + +## Observabilidad y Monitoreo + +### OpenTelemetry como Estándar + +#### **Pilares de Observabilidad** +- **Logs**: Eventos estructurados con contexto (Pino + JSON) +- **Metrics**: Métricas de aplicación y sistema (OpenTelemetry) +- **Traces**: Seguimiento distribuido de requests (Tempo) + +#### **Dashboards y Visualización** +- **Grafana**: Métricas en tiempo real y dashboards customizables +- **Tempo**: Tracing distribuido para debugging de requests complejos + +#### **Alerting y Error Tracking** +- **Sentry**: Captura automática de errores en producción +- **Log Aggregation**: Centralización de logs para análisis + +--- + +## Pirámide de Testing + +### Estrategia Multi-Capa + +#### **Unit Tests (Base de la Pirámide)** +- **Domain Layer**: Lógica de negocio pura (entidades, value objects) +- **Cobertura**: 100% en reglas de negocio críticas +- **Framework**: Jest con assertions descriptivas + +#### **Integration Tests (Capa Media)** +- **Application Layer**: Casos de uso con dependencias mockeadas +- **Use Cases**: Coordinación entre repositorios y servicios externos +- **External APIs**: Contratos con sistemas de mensajería + +#### **E2E Tests (Cima de la Pirámide)** +- **API Endpoints**: Flujos completos desde HTTP hasta base de datos +- **Cross-Service**: Comunicación entre Transaction y Anti-Fraud services +- **Contract Testing**: Validación de interfaces entre servicios + +### Decisiones de Testing + +#### **Herramientas Seleccionadas** +- **Jest**: Framework moderno con TypeScript support +- **Supertest**: Testing HTTP APIs +- **TestContainers**: Base de datos real para tests de integración + +#### **Cobertura y Calidad** +- **Mutation Testing**: Validación de calidad de tests +- **Contract Tests**: Interfaces entre servicios +- **Performance Tests**: Validación de no-regression + +--- + +## Infraestructura de Desarrollo + +### Docker Compose para Desarrollo Local + +El proyecto incluye configuración completa de **Docker Compose** con todos los servicios necesarios: + +#### **Servicios Incluidos** +- **PostgreSQL**: Base de datos relacional +- **Kafka + Zookeeper**: Message broker para comunicación event-driven +- **Redis**: Cache opcional para alta performance +- **Grafana**: Dashboards de métricas +- **Tempo**: Tracing distribuido +- **Kafka UI**: Interfaz web para monitoreo de mensajes + +#### **Configuración por Entorno** +- **Desarrollo**: Servicios locales con volúmenes persistentes +- **Testing**: Base de datos efímera para tests de integración +- **Producción**: Configuración optimizada para deployment + +### Variables de Entorno + +El sistema utiliza **variables de entorno** para configuración flexible: + +#### **Grupos de Configuración** +- **Base de Datos**: Conexión PostgreSQL +- **Mensajería**: Brokers Kafka y grupos de consumidores +- **Servicios**: Puertos y URLs de servicios +- **Observabilidad**: Endpoints de Grafana, Tempo, Sentry +- **Logging**: Niveles y formatos de logs + +--- + +## Referencias Técnicas + +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) +- [Event-Driven Architecture](https://martinfowler.com/articles/201701-event-driven.html) +- [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) +- [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview) +- [NestJS Documentation](https://docs.nestjs.com/) +- [OpenTelemetry Specification](https://opentelemetry.io/docs/) diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000000..a164eb4554 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,29 @@ +# Índice de Documentación Técnica + +Documentación especializada del sistema de transacciones. Para inicio rápido, ver [README.md](../README.md). + +--- + +## Documentos Disponibles + +| Documento | Contenido | +|-----------|-----------| +| **[GUIDE.md](GUIDE.md)** | Arquitectura hexagonal, DDD, patrones de diseño | +| **[FRAUD_RULES.md](FRAUD_RULES.md)** | Motor de reglas anti-fraude y gestión | +| **[API-TESTING.md](API-TESTING.md)** | Guía completa de testing | +| **[Postman Collection](collection/RETO%20YAPE.postman_collection.json)** | Colección oficial de Postman | + +## Diagramas + +| Diagrama | Descripción | +|----------|-------------| +| [architecture-overview](diagrams/architecture-overview-jsoncrack.jpeg) | Vista general del sistema | +| [microservices-flow](diagrams/microservices-flow-diagram.jpeg) | Flujo de comunicación | + +--- + +## Enlaces Rápidos + +- [README.md](../README.md) - Instalación y comandos +- [package.json](../package.json) - Scripts disponibles +- [docker-compose.yml](../docker-compose.yml) - Infraestructura diff --git a/docs/collection/RETO YAPE.postman_collection.json b/docs/collection/RETO YAPE.postman_collection.json new file mode 100644 index 0000000000..63eae73111 --- /dev/null +++ b/docs/collection/RETO YAPE.postman_collection.json @@ -0,0 +1,62 @@ +{ + "info": { + "_postman_id": "542944d9-9b82-46c0-a971-3f9fb59e07b8", + "name": "RETO YAPE", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "33499029" + }, + "item": [ + { + "name": "transactions", + "item": [] + }, + { + "name": "CREAR TRANSACCION", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"accountExternalIdDebit\": \"120e8400-e29b-41d4-a716-446655440000\",\r\n \"accountExternalIdCredit\": \"120e8400-e29b-41d4-a716-446655440001\",\r\n \"tranferTypeId\": 1,\r\n \"value\": 700\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "transactions" + ] + } + }, + "response": [] + }, + { + "name": "CONSULTA DE TRANSACCION", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:3000/transactions/13eace8e-832d-4270-aac6-c8dfee8688c5", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "transactions", + "13eace8e-832d-4270-aac6-c8dfee8688c5" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/docs/diagrams/architecture-overview-jsoncrack.jpeg b/docs/diagrams/architecture-overview-jsoncrack.jpeg new file mode 100644 index 0000000000..20a5b7c3cc Binary files /dev/null and b/docs/diagrams/architecture-overview-jsoncrack.jpeg differ diff --git a/docs/diagrams/microservices-flow-diagram.jpeg b/docs/diagrams/microservices-flow-diagram.jpeg new file mode 100644 index 0000000000..8cd410cd84 Binary files /dev/null and b/docs/diagrams/microservices-flow-diagram.jpeg differ diff --git a/libs/common/src/database/database.config.ts b/libs/common/src/database/database.config.ts new file mode 100644 index 0000000000..b8e80e2923 --- /dev/null +++ b/libs/common/src/database/database.config.ts @@ -0,0 +1,17 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +export interface DatabaseConfig { + url: string; +} + +export const createDrizzleClient = (config: DatabaseConfig) => { + const client = postgres(config.url, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + return drizzle(client); +}; + +export type DrizzleClient = ReturnType; diff --git a/libs/common/src/database/database.constants.ts b/libs/common/src/database/database.constants.ts new file mode 100644 index 0000000000..5e0086cc8b --- /dev/null +++ b/libs/common/src/database/database.constants.ts @@ -0,0 +1,2 @@ +export const DRIZZLE_CLIENT = Symbol('DRIZZLE_CLIENT'); +export const DATABASE_OPTIONS = Symbol('DATABASE_OPTIONS'); diff --git a/libs/common/src/database/database.module.ts b/libs/common/src/database/database.module.ts new file mode 100644 index 0000000000..bf72947dc8 --- /dev/null +++ b/libs/common/src/database/database.module.ts @@ -0,0 +1,66 @@ +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createDrizzleClient, type DatabaseConfig, type DrizzleClient } from './database.config'; +import { DATABASE_OPTIONS, DRIZZLE_CLIENT } from './database.constants'; + +export interface DatabaseModuleOptions { + isGlobal?: boolean; +} + +@Global() +@Module({}) +export class DatabaseModule { + static forRoot(options: DatabaseModuleOptions = {}): DynamicModule { + return { + module: DatabaseModule, + global: options.isGlobal ?? true, + providers: [ + { + provide: DATABASE_OPTIONS, + useValue: options, + }, + { + provide: DRIZZLE_CLIENT, + inject: [ConfigService], + useFactory: (configService: ConfigService): DrizzleClient => { + const databaseUrl = configService.get('DATABASE_URL'); + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); + } + const config: DatabaseConfig = { + url: databaseUrl, + }; + return createDrizzleClient(config); + }, + }, + ], + exports: [DRIZZLE_CLIENT], + }; + } + + static forRootAsync(options: { + isGlobal?: boolean; + useFactory: (...args: any[]) => DatabaseConfig | Promise; + inject?: any[]; + }): DynamicModule { + return { + module: DatabaseModule, + global: options.isGlobal ?? true, + providers: [ + { + provide: DATABASE_OPTIONS, + useValue: { isGlobal: options.isGlobal }, + }, + { + provide: DRIZZLE_CLIENT, + inject: options.inject || [], + useFactory: async (...args: any[]): Promise => { + const config = await options.useFactory(...args); + return createDrizzleClient(config); + }, + }, + ], + exports: [DRIZZLE_CLIENT], + }; + } +} diff --git a/libs/common/src/events/transaction.events.ts b/libs/common/src/events/transaction.events.ts new file mode 100644 index 0000000000..7da04b01a1 --- /dev/null +++ b/libs/common/src/events/transaction.events.ts @@ -0,0 +1,29 @@ +import type { EventMetadata } from '../types/tracing.types'; + +export interface TransactionCreatedEvent { + transactionExternalId: string; + accountExternalIdDebit: string; + accountExternalIdCredit: string; + transferTypeId: number; + value: number; + transactionStatus: string; + createdAt: Date | string; + metadata: EventMetadata; +} + +export interface TransactionStatusUpdatedEvent { + transactionExternalId: string; + transactionStatus: 'approved' | 'rejected'; + reason?: string; + validatedAt?: Date | string; + metadata: EventMetadata; +} + +export interface TransactionApprovedEvent extends TransactionStatusUpdatedEvent { + transactionStatus: 'approved'; +} + +export interface TransactionRejectedEvent extends TransactionStatusUpdatedEvent { + transactionStatus: 'rejected'; + reason: string; +} diff --git a/libs/common/src/exceptions/filters/global-exception.filter.ts b/libs/common/src/exceptions/filters/global-exception.filter.ts new file mode 100644 index 0000000000..8dc6c515f2 --- /dev/null +++ b/libs/common/src/exceptions/filters/global-exception.filter.ts @@ -0,0 +1,45 @@ +import type { LoggerService } from '@app/observability'; +import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { SentryExceptionCaptured } from '@sentry/nestjs'; + +@Injectable() +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger: LoggerService) {} + + @SentryExceptionCaptured() + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; + + const requestContext = { + correlationId: request.headers['x-correlation-id'] || 'unknown', + requestId: request.headers['x-request-id'] || 'unknown', + userId: request.user?.id, + ipAddress: request.ip, + userAgent: request.headers['user-agent'], + }; + + this.logger.error( + `Unhandled exception: ${status}`, + exception instanceof Error ? exception : new Error(String(exception)), + requestContext, + { + path: request.url, + method: request.method, + }, + ); + + response.status(status).json({ + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + message: typeof message === 'object' ? (message as any).message : message, + }); + } +} diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts new file mode 100644 index 0000000000..7719287ea4 --- /dev/null +++ b/libs/common/src/index.ts @@ -0,0 +1,16 @@ +export * from './database/database.config'; +export * from './database/database.constants'; +export * from './database/database.module'; +export * from './events/transaction.events'; +export * from './exceptions/filters/global-exception.filter'; +export * from './interceptors/correlation-id.interceptor'; +export * from './kafka/kafka.config'; +export * from './kafka/kafka.constants'; +export * from './kafka/kafka.consumer'; +export * from './kafka/kafka.module'; +export * from './kafka/kafka.producer'; +export * from './redis/redis.module'; +export * from './redis/redis.service'; +export * from './types/fraud-rules.types'; +export * from './types/tracing.types'; +export * from './types/transaction.types'; diff --git a/libs/common/src/interceptors/correlation-id.interceptor.ts b/libs/common/src/interceptors/correlation-id.interceptor.ts new file mode 100644 index 0000000000..840e5a684a --- /dev/null +++ b/libs/common/src/interceptors/correlation-id.interceptor.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'node:crypto'; +import { type CallHandler, type ExecutionContext, Injectable, type NestInterceptor } from '@nestjs/common'; +import type { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +export const CORRELATION_ID_HEADER = 'x-correlation-id'; +export const REQUEST_ID_HEADER = 'x-request-id'; + +@Injectable() +export class CorrelationIdInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + const correlationId = request.headers[CORRELATION_ID_HEADER] || randomUUID(); + + const requestId = randomUUID(); + + request.correlationId = correlationId; + request.requestId = requestId; + + response.setHeader(CORRELATION_ID_HEADER, correlationId); + response.setHeader(REQUEST_ID_HEADER, requestId); + + const startTime = Date.now(); + + return next.handle().pipe( + tap(() => { + const duration = Date.now() - startTime; + request.duration = duration; + }), + ); + } +} diff --git a/libs/common/src/kafka/kafka.config.ts b/libs/common/src/kafka/kafka.config.ts new file mode 100644 index 0000000000..7f0b2477f2 --- /dev/null +++ b/libs/common/src/kafka/kafka.config.ts @@ -0,0 +1,22 @@ +import { type ConsumerConfig, Kafka, type KafkaConfig, type ProducerConfig } from 'kafkajs'; + +export interface KafkaModuleOptions { + clientId: string; + brokers: string[]; + producerConfig?: ProducerConfig; + consumerConfig?: ConsumerConfig; +} + +export const createKafkaClient = (options: KafkaModuleOptions): Kafka => { + const kafkaConfig: KafkaConfig = { + clientId: options.clientId, + brokers: options.brokers, + retry: { + retries: 8, + initialRetryTime: 100, + maxRetryTime: 30000, + }, + }; + + return new Kafka(kafkaConfig); +}; diff --git a/libs/common/src/kafka/kafka.constants.ts b/libs/common/src/kafka/kafka.constants.ts new file mode 100644 index 0000000000..fd3285800b --- /dev/null +++ b/libs/common/src/kafka/kafka.constants.ts @@ -0,0 +1,9 @@ +export const KAFKA_PRODUCER = Symbol('KAFKA_PRODUCER'); +export const KAFKA_CONSUMER = Symbol('KAFKA_CONSUMER'); +export const KAFKA_OPTIONS = Symbol('KAFKA_OPTIONS'); + +export enum KafkaTopics { + TRANSACTION_CREATED = 'transaction-created', + TRANSACTION_APPROVED = 'transaction-approved', + TRANSACTION_REJECTED = 'transaction-rejected', +} diff --git a/libs/common/src/kafka/kafka.consumer.spec.ts b/libs/common/src/kafka/kafka.consumer.spec.ts new file mode 100644 index 0000000000..737d5265bc --- /dev/null +++ b/libs/common/src/kafka/kafka.consumer.spec.ts @@ -0,0 +1,286 @@ +import type { Consumer, EachMessagePayload } from 'kafkajs'; +import type { KafkaModuleOptions } from './kafka.config'; +import { KafkaConsumerService, type MessageHandler } from './kafka.consumer'; + +jest.mock('./kafka.config', () => ({ + createKafkaClient: jest.fn(() => ({ + consumer: jest.fn(() => mockConsumer), + })), +})); + +let mockConsumer: jest.Mocked; +let capturedEachMessage: ((payload: EachMessagePayload) => Promise) | undefined; + +describe('KafkaConsumerService', () => { + let service: KafkaConsumerService; + let consoleLogSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + const mockOptions: KafkaModuleOptions = { + clientId: 'test-consumer-client', + brokers: ['localhost:9092'], + consumerConfig: { + groupId: 'test-group', + }, + }; + + beforeEach(() => { + capturedEachMessage = undefined; + + mockConsumer = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + subscribe: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockImplementation((config) => { + capturedEachMessage = config.eachMessage; + return Promise.resolve(); + }), + } as any; + + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + service = new KafkaConsumerService(mockOptions); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + describe('constructor', () => { + it('should throw error if consumerConfig is not provided', () => { + const invalidOptions: KafkaModuleOptions = { + clientId: 'test', + brokers: ['localhost:9092'], + }; + + expect(() => new KafkaConsumerService(invalidOptions)).toThrow( + 'consumerConfig is required for KafkaConsumerService', + ); + }); + }); + + describe('onModuleInit', () => { + it('should connect to Kafka consumer', async () => { + await service.onModuleInit(); + + expect(mockConsumer.connect).toHaveBeenCalledTimes(1); + }); + + it('should log successful connection', async () => { + await service.onModuleInit(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Kafka Consumer connected')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('test-consumer-client')); + }); + + it('should not start consuming if no handlers registered', async () => { + await service.onModuleInit(); + + expect(mockConsumer.run).not.toHaveBeenCalled(); + }); + + it('should start consuming if handlers are registered', async () => { + const handler = jest.fn(); + service.registerHandler('test-topic', handler); + + await service.onModuleInit(); + + expect(mockConsumer.run).toHaveBeenCalled(); + }); + }); + + describe('onModuleDestroy', () => { + it('should disconnect from Kafka consumer', async () => { + await service.onModuleDestroy(); + + expect(mockConsumer.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should log disconnection', async () => { + await service.onModuleDestroy(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Kafka Consumer disconnected')); + }); + }); + + describe('registerHandler', () => { + it('should register a message handler for a topic', () => { + const handler: MessageHandler = jest.fn(); + + service.registerHandler('test-topic', handler); + + // Verificar que se registró internamente + expect(() => service.registerHandler('test-topic', handler)).not.toThrow(); + }); + + it('should allow registering multiple handlers for different topics', () => { + const handler1: MessageHandler = jest.fn(); + const handler2: MessageHandler = jest.fn(); + + service.registerHandler('topic-1', handler1); + service.registerHandler('topic-2', handler2); + + expect(() => service.registerHandler('topic-1', handler1)).not.toThrow(); + expect(() => service.registerHandler('topic-2', handler2)).not.toThrow(); + }); + }); + + describe('subscribe', () => { + it('should subscribe to a single topic', async () => { + await service.subscribe(['test-topic']); + + expect(mockConsumer.subscribe).toHaveBeenCalledWith({ + topic: 'test-topic', + fromBeginning: false, + }); + }); + + it('should subscribe to multiple topics', async () => { + await service.subscribe(['topic-1', 'topic-2', 'topic-3']); + + expect(mockConsumer.subscribe).toHaveBeenCalledTimes(3); + expect(mockConsumer.subscribe).toHaveBeenCalledWith({ + topic: 'topic-1', + fromBeginning: false, + }); + expect(mockConsumer.subscribe).toHaveBeenCalledWith({ + topic: 'topic-2', + fromBeginning: false, + }); + }); + + it('should log subscription for each topic', async () => { + await service.subscribe(['topic-1', 'topic-2']); + + expect(consoleLogSpy).toHaveBeenCalledWith('📥 Subscribed to topic: topic-1'); + expect(consoleLogSpy).toHaveBeenCalledWith('📥 Subscribed to topic: topic-2'); + }); + }); + + describe('subscribeAndRun', () => { + it('should subscribe and start consuming', async () => { + const handler = jest.fn(); + service.registerHandler('test-topic', handler); + + await service.subscribeAndRun(['test-topic']); + + expect(mockConsumer.subscribe).toHaveBeenCalled(); + expect(mockConsumer.run).toHaveBeenCalled(); + }); + + it('should subscribe to multiple topics and start consuming', async () => { + const handler1 = jest.fn(); + const handler2 = jest.fn(); + service.registerHandler('topic-1', handler1); + service.registerHandler('topic-2', handler2); + + await service.subscribeAndRun(['topic-1', 'topic-2']); + + expect(mockConsumer.subscribe).toHaveBeenCalledTimes(2); + expect(mockConsumer.run).toHaveBeenCalled(); + }); + }); + + describe('message handling', () => { + it('should call registered handler when message is received', async () => { + const handler = jest.fn().mockResolvedValue(undefined); + service.registerHandler('test-topic', handler); + + await service.subscribeAndRun(['test-topic']); + + const mockPayload: EachMessagePayload = { + topic: 'test-topic', + partition: 0, + message: { + key: Buffer.from('key'), + value: Buffer.from('value'), + timestamp: '123', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await capturedEachMessage?.(mockPayload); + + expect(handler).toHaveBeenCalledWith(mockPayload); + }); + + it('should warn when no handler is registered for a topic', async () => { + const handler = jest.fn(); + service.registerHandler('registered-topic', handler); + + await service.subscribeAndRun(['registered-topic']); + + const mockPayload: EachMessagePayload = { + topic: 'unregistered-topic', + partition: 0, + message: { + key: Buffer.from('key'), + value: Buffer.from('value'), + timestamp: '123', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await capturedEachMessage?.(mockPayload); + + expect(consoleWarnSpy).toHaveBeenCalledWith('No handler registered for topic: unregistered-topic'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should handle multiple messages for different topics', async () => { + const handler1 = jest.fn().mockResolvedValue(undefined); + const handler2 = jest.fn().mockResolvedValue(undefined); + + service.registerHandler('topic-1', handler1); + service.registerHandler('topic-2', handler2); + + await service.subscribeAndRun(['topic-1', 'topic-2']); + + const payload1: EachMessagePayload = { + topic: 'topic-1', + partition: 0, + message: { + key: Buffer.from('key1'), + value: Buffer.from('value1'), + timestamp: '123', + attributes: 0, + offset: '0', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + const payload2: EachMessagePayload = { + topic: 'topic-2', + partition: 0, + message: { + key: Buffer.from('key2'), + value: Buffer.from('value2'), + timestamp: '456', + attributes: 0, + offset: '1', + headers: {}, + }, + heartbeat: async () => {}, + pause: () => () => {}, + }; + + await capturedEachMessage?.(payload1); + await capturedEachMessage?.(payload2); + + expect(handler1).toHaveBeenCalledWith(payload1); + expect(handler2).toHaveBeenCalledWith(payload2); + }); + }); +}); diff --git a/libs/common/src/kafka/kafka.consumer.ts b/libs/common/src/kafka/kafka.consumer.ts new file mode 100644 index 0000000000..808bb853b9 --- /dev/null +++ b/libs/common/src/kafka/kafka.consumer.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import type { Consumer, EachMessagePayload } from 'kafkajs'; +import { createKafkaClient, type KafkaModuleOptions } from './kafka.config'; +import { KAFKA_OPTIONS } from './kafka.constants'; + +export type MessageHandler = (payload: EachMessagePayload) => Promise; + +@Injectable() +export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy { + private consumer: Consumer; + private messageHandlers: Map = new Map(); + + constructor( + @Inject(KAFKA_OPTIONS) + private readonly options: KafkaModuleOptions, + ) { + const kafka = createKafkaClient(this.options); + if (!this.options.consumerConfig) { + throw new Error('consumerConfig is required for KafkaConsumerService'); + } + this.consumer = kafka.consumer(this.options.consumerConfig); + } + + async onModuleInit() { + await this.consumer.connect(); + console.log(`✅ Kafka Consumer connected (${this.options.clientId})`); + + if (this.messageHandlers.size > 0) { + await this.startConsuming(); + } + } + + async onModuleDestroy() { + await this.consumer.disconnect(); + console.log(`❌ Kafka Consumer disconnected (${this.options.clientId})`); + } + + registerHandler(topic: string, handler: MessageHandler): void { + this.messageHandlers.set(topic, handler); + } + + async subscribe(topics: string[]): Promise { + for (const topic of topics) { + await this.consumer.subscribe({ topic, fromBeginning: false }); + console.log(`📥 Subscribed to topic: ${topic}`); + } + } + + private async startConsuming(): Promise { + await this.consumer.run({ + eachMessage: async (payload: EachMessagePayload) => { + const handler = this.messageHandlers.get(payload.topic); + if (handler) { + await handler(payload); + } else { + console.warn(`No handler registered for topic: ${payload.topic}`); + } + }, + }); + } + + async subscribeAndRun(topics: string[]): Promise { + await this.subscribe(topics); + await this.startConsuming(); + } +} diff --git a/libs/common/src/kafka/kafka.module.ts b/libs/common/src/kafka/kafka.module.ts new file mode 100644 index 0000000000..1574d5542b --- /dev/null +++ b/libs/common/src/kafka/kafka.module.ts @@ -0,0 +1,48 @@ +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import type { KafkaModuleOptions } from './kafka.config'; +import { KAFKA_OPTIONS } from './kafka.constants'; +import { KafkaConsumerService } from './kafka.consumer'; +import { KafkaProducerService } from './kafka.producer'; + +export interface KafkaModuleAsyncOptions { + isGlobal?: boolean; + useFactory: (...args: any[]) => KafkaModuleOptions | Promise; + inject?: any[]; +} + +@Global() +@Module({}) +export class KafkaModule { + static forRoot(options: KafkaModuleOptions & { isGlobal?: boolean }): DynamicModule { + return { + module: KafkaModule, + global: options.isGlobal ?? true, + providers: [ + { + provide: KAFKA_OPTIONS, + useValue: options, + }, + KafkaProducerService, + KafkaConsumerService, + ], + exports: [KafkaProducerService, KafkaConsumerService], + }; + } + + static forRootAsync(options: KafkaModuleAsyncOptions): DynamicModule { + return { + module: KafkaModule, + global: options.isGlobal ?? true, + providers: [ + { + provide: KAFKA_OPTIONS, + inject: options.inject || [], + useFactory: options.useFactory, + }, + KafkaProducerService, + KafkaConsumerService, + ], + exports: [KafkaProducerService, KafkaConsumerService], + }; + } +} diff --git a/libs/common/src/kafka/kafka.producer.spec.ts b/libs/common/src/kafka/kafka.producer.spec.ts new file mode 100644 index 0000000000..301ac3bd0e --- /dev/null +++ b/libs/common/src/kafka/kafka.producer.spec.ts @@ -0,0 +1,132 @@ +import type { Producer } from 'kafkajs'; +import type { KafkaModuleOptions } from './kafka.config'; +import { KafkaProducerService } from './kafka.producer'; + +jest.mock('./kafka.config', () => ({ + createKafkaClient: jest.fn(() => ({ + producer: jest.fn(() => mockProducer), + })), +})); + +let mockProducer: jest.Mocked; + +describe('KafkaProducerService', () => { + let service: KafkaProducerService; + let consoleLogSpy: jest.SpyInstance; + + const mockOptions: KafkaModuleOptions = { + clientId: 'test-client', + brokers: ['localhost:9092'], + }; + + beforeEach(() => { + mockProducer = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + send: jest.fn().mockResolvedValue(undefined), + } as any; + + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + service = new KafkaProducerService(mockOptions); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleLogSpy.mockRestore(); + }); + + describe('onModuleInit', () => { + it('should connect to Kafka producer', async () => { + await service.onModuleInit(); + + expect(mockProducer.connect).toHaveBeenCalledTimes(1); + }); + + it('should log successful connection', async () => { + await service.onModuleInit(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Kafka Producer connected')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('test-client')); + }); + }); + + describe('onModuleDestroy', () => { + it('should disconnect from Kafka producer', async () => { + await service.onModuleDestroy(); + + expect(mockProducer.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should log disconnection', async () => { + await service.onModuleDestroy(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Kafka Producer disconnected')); + }); + }); + + describe('send', () => { + it('should send a producer record', async () => { + const record = { + topic: 'test-topic', + messages: [ + { + key: 'key-1', + value: 'value-1', + }, + ], + }; + + await service.send(record); + + expect(mockProducer.send).toHaveBeenCalledWith(record); + }); + }); + + describe('sendMessage', () => { + it('should send a message with JSON serialization', async () => { + const topic = 'test-topic'; + const key = 'test-key'; + const value = { id: 1, name: 'test' }; + + await service.sendMessage(topic, key, value); + + expect(mockProducer.send).toHaveBeenCalledWith({ + topic: 'test-topic', + messages: [ + { + key: 'test-key', + value: JSON.stringify(value), + }, + ], + }); + }); + + it('should handle string values', async () => { + await service.sendMessage('topic', 'key', 'simple-string'); + + expect(mockProducer.send).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + value: '"simple-string"', + }), + ], + }), + ); + }); + + it('should handle number values', async () => { + await service.sendMessage('topic', 'key', 12345); + + expect(mockProducer.send).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + value: '12345', + }), + ], + }), + ); + }); + }); +}); diff --git a/libs/common/src/kafka/kafka.producer.ts b/libs/common/src/kafka/kafka.producer.ts new file mode 100644 index 0000000000..e8134d2412 --- /dev/null +++ b/libs/common/src/kafka/kafka.producer.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import type { Producer, ProducerRecord } from 'kafkajs'; +import { createKafkaClient, type KafkaModuleOptions } from './kafka.config'; +import { KAFKA_OPTIONS } from './kafka.constants'; + +@Injectable() +export class KafkaProducerService implements OnModuleInit, OnModuleDestroy { + private producer: Producer; + + constructor( + @Inject(KAFKA_OPTIONS) + private readonly options: KafkaModuleOptions, + ) { + const kafka = createKafkaClient(this.options); + this.producer = kafka.producer(this.options.producerConfig); + } + + async onModuleInit() { + await this.producer.connect(); + console.log(`✅ Kafka Producer connected (${this.options.clientId})`); + } + + async onModuleDestroy() { + await this.producer.disconnect(); + console.log(`❌ Kafka Producer disconnected (${this.options.clientId})`); + } + + async send(record: ProducerRecord): Promise { + await this.producer.send(record); + } + + async sendMessage(topic: string, key: string, value: T): Promise { + await this.producer.send({ + topic, + messages: [ + { + key, + value: JSON.stringify(value), + }, + ], + }); + } +} diff --git a/libs/common/src/redis/redis.module.ts b/libs/common/src/redis/redis.module.ts new file mode 100644 index 0000000000..9717d7e10b --- /dev/null +++ b/libs/common/src/redis/redis.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/libs/common/src/redis/redis.service.spec.ts b/libs/common/src/redis/redis.service.spec.ts new file mode 100644 index 0000000000..bf3bf700f8 --- /dev/null +++ b/libs/common/src/redis/redis.service.spec.ts @@ -0,0 +1,183 @@ +import type { ConfigService } from '@nestjs/config'; +import type Redis from 'ioredis'; +import { RedisService } from './redis.service'; + +jest.mock('ioredis', () => { + return jest.fn().mockImplementation(() => mockRedisClient); +}); + +let mockRedisClient: jest.Mocked; + +describe('RedisService', () => { + let service: RedisService; + let mockConfigService: jest.Mocked; + + beforeEach(() => { + mockRedisClient = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + disconnect: jest.fn(), + } as any; + + mockConfigService = { + get: jest.fn(), + } as any; + + service = new RedisService(mockConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('onModuleInit', () => { + it('should initialize Redis with URL when REDIS_URL is provided', () => { + mockConfigService.get.mockReturnValue('redis://localhost:6379'); + + service.onModuleInit(); + + expect(mockConfigService.get).toHaveBeenCalledWith('REDIS_URL'); + }); + + it('should throw error when REDIS_URL is not provided', () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'REDIS_URL') return null; + return undefined; + }); + + expect(() => service.onModuleInit()).toThrow('REDIS_URL environment variable is required'); + expect(mockConfigService.get).toHaveBeenCalledWith('REDIS_URL'); + }); + }); + + describe('onModuleDestroy', () => { + it('should disconnect Redis client', () => { + mockConfigService.get.mockReturnValue('redis://localhost:6379'); + service.onModuleInit(); + service.onModuleDestroy(); + + expect(mockRedisClient.disconnect).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + beforeEach(() => { + mockConfigService.get.mockReturnValue('redis://localhost:6379'); + service.onModuleInit(); + }); + + it('should get and parse JSON data from Redis', async () => { + const data = { id: 1, name: 'test' }; + mockRedisClient.get.mockResolvedValue(JSON.stringify(data)); + + const result = await service.get('test-key'); + + expect(mockRedisClient.get).toHaveBeenCalledWith('test-key'); + expect(result).toEqual(data); + }); + + it('should return null when key does not exist', async () => { + mockRedisClient.get.mockResolvedValue(null); + + const result = await service.get('non-existent-key'); + + expect(result).toBeNull(); + }); + + it('should handle complex objects', async () => { + const complexData = { + id: 123, + nested: { + value: 'test', + array: [1, 2, 3], + }, + }; + mockRedisClient.get.mockResolvedValue(JSON.stringify(complexData)); + + const result = await service.get('complex-key'); + + expect(result).toEqual(complexData); + }); + + it('should handle arrays', async () => { + const arrayData = [1, 2, 3, 4, 5]; + mockRedisClient.get.mockResolvedValue(JSON.stringify(arrayData)); + + const result = await service.get('array-key'); + + expect(result).toEqual(arrayData); + }); + }); + + describe('set', () => { + beforeEach(() => { + mockConfigService.get.mockReturnValue('redis://localhost:6379'); + service.onModuleInit(); + }); + + it('should set data with default TTL', async () => { + const data = { id: 1, name: 'test' }; + + await service.set('test-key', data); + + expect(mockRedisClient.set).toHaveBeenCalledWith('test-key', JSON.stringify(data), 'EX', 600); + }); + + it('should set data with custom TTL', async () => { + const data = { id: 1, name: 'test' }; + const ttl = 3600; + + await service.set('test-key', data, ttl); + + expect(mockRedisClient.set).toHaveBeenCalledWith('test-key', JSON.stringify(data), 'EX', 3600); + }); + + it('should serialize complex objects', async () => { + const complexData = { + id: 123, + nested: { + value: 'test', + array: [1, 2, 3], + }, + }; + + await service.set('complex-key', complexData); + + expect(mockRedisClient.set).toHaveBeenCalledWith('complex-key', JSON.stringify(complexData), 'EX', 600); + }); + + it('should handle string values', async () => { + await service.set('string-key', 'simple string'); + + expect(mockRedisClient.set).toHaveBeenCalledWith('string-key', '"simple string"', 'EX', 600); + }); + + it('should handle number values', async () => { + await service.set('number-key', 12345); + + expect(mockRedisClient.set).toHaveBeenCalledWith('number-key', '12345', 'EX', 600); + }); + }); + + describe('del', () => { + beforeEach(() => { + mockConfigService.get.mockReturnValue('redis://localhost:6379'); + service.onModuleInit(); + }); + + it('should delete a key from Redis', async () => { + await service.del('test-key'); + + expect(mockRedisClient.del).toHaveBeenCalledWith('test-key'); + }); + + it('should handle deletion of non-existent keys', async () => { + mockRedisClient.del.mockResolvedValue(0); + + await service.del('non-existent-key'); + + expect(mockRedisClient.del).toHaveBeenCalledWith('non-existent-key'); + }); + }); +}); diff --git a/libs/common/src/redis/redis.service.ts b/libs/common/src/redis/redis.service.ts new file mode 100644 index 0000000000..66776a4d84 --- /dev/null +++ b/libs/common/src/redis/redis.service.ts @@ -0,0 +1,36 @@ +import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleInit, OnModuleDestroy { + private redisClient: Redis; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + const redisUrl = this.configService.get('REDIS_URL'); + if (!redisUrl) { + throw new Error('REDIS_URL environment variable is required'); + } + + this.redisClient = new Redis(redisUrl); + } + + onModuleDestroy() { + this.redisClient.disconnect(); + } + + async get(key: string): Promise { + const data = await this.redisClient.get(key); + return data ? JSON.parse(data) : null; + } + + async set(key: string, value: T, ttlSeconds: number = 600): Promise { + await this.redisClient.set(key, JSON.stringify(value), 'EX', ttlSeconds); + } + + async del(key: string): Promise { + await this.redisClient.del(key); + } +} diff --git a/libs/common/src/types/fraud-rules.types.ts b/libs/common/src/types/fraud-rules.types.ts new file mode 100644 index 0000000000..67f23db634 --- /dev/null +++ b/libs/common/src/types/fraud-rules.types.ts @@ -0,0 +1,70 @@ +export enum FraudRuleType { + AMOUNT_THRESHOLD = 'AMOUNT_THRESHOLD', + DAILY_LIMIT = 'DAILY_LIMIT', + VELOCITY_CHECK = 'VELOCITY_CHECK', + ACCOUNT_BLACKLIST = 'ACCOUNT_BLACKLIST', + TRANSFER_TYPE_LIMIT = 'TRANSFER_TYPE_LIMIT', + TIME_BASED = 'TIME_BASED', +} + +export enum FraudAction { + REJECT = 'REJECT', + APPROVE = 'APPROVE', + REVIEW = 'REVIEW', + FLAG = 'FLAG', +} + +export interface AmountThresholdCondition { + operator: 'gt' | 'gte' | 'lt' | 'lte' | 'eq'; + value: number; +} + +export interface DailyLimitCondition { + maxAmount: number; + maxTransactions: number; +} + +export interface VelocityCheckCondition { + maxTransactions: number; + timeWindowMinutes: number; +} + +export interface AccountBlacklistCondition { + accounts: string[]; +} + +export interface TransferTypeLimitCondition { + transferTypeId: number; + maxAmount: number; +} + +export interface TimeBasedCondition { + allowedHours: { start: number; end: number }; + allowedDays: number[]; +} + +export type FraudRuleCondition = + | AmountThresholdCondition + | DailyLimitCondition + | VelocityCheckCondition + | AccountBlacklistCondition + | TransferTypeLimitCondition + | TimeBasedCondition; + +export interface FraudRuleEvaluationResult { + ruleId: string; + ruleName: string; + matched: boolean; + action: FraudAction; + reason?: string; + details?: any; +} + +export interface FraudEvaluationContext { + transactionExternalId: string; + accountExternalIdDebit: string; + accountExternalIdCredit: string; + transferTypeId: number; + value: number; + createdAt: Date; +} diff --git a/libs/common/src/types/tracing.types.ts b/libs/common/src/types/tracing.types.ts new file mode 100644 index 0000000000..5d83d19edd --- /dev/null +++ b/libs/common/src/types/tracing.types.ts @@ -0,0 +1,28 @@ +export interface RequestContext { + correlationId: string; + requestId: string; + timestamp: string; + service: string; + userId?: string; + ipAddress?: string; + userAgent?: string; +} + +export interface LogContext extends RequestContext { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + metadata?: Record; + error?: { + message: string; + stack?: string; + code?: string; + }; +} + +export interface EventMetadata { + correlationId: string; + causationId: string; + timestamp: string; + service: string; + version?: string; +} diff --git a/libs/common/src/types/transaction.types.ts b/libs/common/src/types/transaction.types.ts new file mode 100644 index 0000000000..13875096a3 --- /dev/null +++ b/libs/common/src/types/transaction.types.ts @@ -0,0 +1,17 @@ +export enum TransactionStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +export enum TransactionType { + TRANSFER = 1, + PAYMENT = 2, + WITHDRAWAL = 3, +} + +export const TransactionTypeNames: Record = { + [TransactionType.TRANSFER]: 'Transfer', + [TransactionType.PAYMENT]: 'Payment', + [TransactionType.WITHDRAWAL]: 'Withdrawal', +}; diff --git a/libs/common/tsconfig.lib.json b/libs/common/tsconfig.lib.json new file mode 100644 index 0000000000..7911731e24 --- /dev/null +++ b/libs/common/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/common", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/observability/src/adapters/composite-logger.adapter.ts b/libs/observability/src/adapters/composite-logger.adapter.ts new file mode 100644 index 0000000000..03c50f8d2d --- /dev/null +++ b/libs/observability/src/adapters/composite-logger.adapter.ts @@ -0,0 +1,58 @@ +import type { RequestContext } from '@app/common/types/tracing.types'; +import { Injectable } from '@nestjs/common'; +import type { ILogger } from '../ports/logger.port'; + +@Injectable() +export class CompositeLoggerAdapter implements ILogger { + constructor(private readonly loggers: ILogger[]) {} + + log(message: string, context?: Partial, metadata?: Record): void { + this.loggers.forEach((logger) => { + try { + logger.log(message, context, metadata); + } catch (error) { + console.error(`Logger failed:`, error); + } + }); + } + + error(message: string, error?: Error, context?: Partial, metadata?: Record): void { + this.loggers.forEach((logger) => { + try { + logger.error(message, error, context, metadata); + } catch (err) { + console.error(`Logger failed:`, err); + } + }); + } + + warn(message: string, context?: Partial, metadata?: Record): void { + this.loggers.forEach((logger) => { + try { + logger.warn(message, context, metadata); + } catch (error) { + console.error(`Logger failed:`, error); + } + }); + } + + debug(message: string, context?: Partial, metadata?: Record): void { + this.loggers.forEach((logger) => { + try { + logger.debug(message, context, metadata); + } catch (error) { + console.error(`Logger failed:`, error); + } + }); + } + + verbose(message: string, context?: Partial, metadata?: Record): void { + this.loggers.forEach((logger) => { + try { + logger.verbose(message, context, metadata); + } catch (error) { + console.error(`Logger failed:`, error); + } + }); + } +} diff --git a/libs/observability/src/adapters/console-logger.adapter.ts b/libs/observability/src/adapters/console-logger.adapter.ts new file mode 100644 index 0000000000..8706f66e88 --- /dev/null +++ b/libs/observability/src/adapters/console-logger.adapter.ts @@ -0,0 +1,60 @@ +import type { LogContext, RequestContext } from '@app/common/types/tracing.types'; +import { Injectable } from '@nestjs/common'; +import type { ILogger } from '../ports/logger.port'; + +@Injectable() +export class ConsoleLoggerAdapter implements ILogger { + constructor(private readonly serviceName: string) {} + + private buildLogContext( + level: LogContext['level'], + message: string, + context?: Partial, + metadata?: Record, + error?: Error, + ): LogContext { + return { + correlationId: context?.correlationId || 'unknown', + requestId: context?.requestId || 'unknown', + timestamp: new Date().toISOString(), + service: this.serviceName, + userId: context?.userId, + ipAddress: context?.ipAddress, + userAgent: context?.userAgent, + level, + message, + metadata, + error: error + ? { + message: error.message, + stack: error.stack, + code: (error as any).code, + } + : undefined, + }; + } + + private output(logContext: LogContext): void { + console.log(JSON.stringify(logContext)); + } + + log(message: string, context?: Partial, metadata?: Record): void { + this.output(this.buildLogContext('info', message, context, metadata)); + } + + error(message: string, error?: Error, context?: Partial, metadata?: Record): void { + this.output(this.buildLogContext('error', message, context, metadata, error)); + } + + warn(message: string, context?: Partial, metadata?: Record): void { + this.output(this.buildLogContext('warn', message, context, metadata)); + } + + debug(message: string, context?: Partial, metadata?: Record): void { + this.output(this.buildLogContext('debug', message, context, metadata)); + } + + verbose(message: string, context?: Partial, metadata?: Record): void { + this.output(this.buildLogContext('debug', message, context, metadata)); + } +} diff --git a/libs/observability/src/adapters/sentry-logger.adapter.ts b/libs/observability/src/adapters/sentry-logger.adapter.ts new file mode 100644 index 0000000000..9a251ec67d --- /dev/null +++ b/libs/observability/src/adapters/sentry-logger.adapter.ts @@ -0,0 +1,80 @@ +import type { RequestContext } from '@app/common/types/tracing.types'; +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import type { ILogger } from '../ports/logger.port'; + +@Injectable() +export class SentryLoggerAdapter implements ILogger { + private serviceName: string; + + constructor(serviceName: string) { + this.serviceName = serviceName; + } + + private captureWithContext( + level: 'info' | 'warning' | 'error' | 'debug', + message: string, + context?: Partial, + metadata?: Record, + error?: Error, + ): void { + Sentry.withScope((scope: any) => { + scope.setTag('service', this.serviceName); + scope.setTag('correlation_id', context?.correlationId || 'unknown'); + scope.setTag('request_id', context?.requestId || 'unknown'); + scope.setLevel(level); + + if (context?.userId) { + scope.setUser({ + id: context.userId, + ip_address: context.ipAddress, + }); + } + + scope.setContext('request', { + correlationId: context?.correlationId, + requestId: context?.requestId, + ipAddress: context?.ipAddress, + userAgent: context?.userAgent, + timestamp: new Date().toISOString(), + }); + + if (metadata) { + scope.setContext('metadata', metadata); + } + + if (error) { + scope.setContext('error_details', { + name: error.name, + message: error.message, + code: (error as any).code, + }); + Sentry.captureException(error); + } else { + Sentry.captureMessage(message, level); + } + }); + } + + log(message: string, context?: Partial, metadata?: Record): void { + if (process.env.NODE_ENV === 'development') { + this.captureWithContext('info', message, context, metadata); + } + } + + error(message: string, error?: Error, context?: Partial, metadata?: Record): void { + this.captureWithContext('error', message, context, metadata, error); + } + + warn(message: string, context?: Partial, metadata?: Record): void { + this.captureWithContext('warning', message, context, metadata); + } + + debug(message: string, context?: Partial, metadata?: Record): void { + if (process.env.SENTRY_DEBUG === 'true') { + this.captureWithContext('debug', message, context, metadata); + } + } + + verbose(_message: string, _context?: Partial, _metadata?: Record): void {} +} diff --git a/libs/observability/src/index.ts b/libs/observability/src/index.ts new file mode 100644 index 0000000000..1b25de5a1e --- /dev/null +++ b/libs/observability/src/index.ts @@ -0,0 +1,36 @@ +export { context, propagation, Span as OtelSpan, trace } from '@opentelemetry/api'; +export { CompositeLoggerAdapter } from './adapters/composite-logger.adapter'; +export { ConsoleLoggerAdapter } from './adapters/console-logger.adapter'; +export { SentryLoggerAdapter } from './adapters/sentry-logger.adapter'; +export { + extractTraceContext, + getTraceHeaders, + injectTraceContext, + withProducerSpan, + wrapKafkaHandler, +} from './kafka/kafka-tracing.helper'; +export { LoggerService } from './logger.service'; +export { HttpLoggerInterceptor } from './logging/http-logger.interceptor'; +export { LoggingModule } from './logging/logging.module'; +export { + createPinoLogger, + LogContext, + PinoLoggerService, +} from './logging/pino-logger.service'; +export { ObservabilityModule } from './observability.module'; +export { type ILogger, LOGGER_PORT } from './ports/logger.port'; +export { + getCurrentTraceContext, + getTracer, + initializeTelemetry, + shutdownTelemetry, + TelemetryConfig, +} from './tracing/otel'; +export { + KafkaConsumerSpan, + RepositorySpan, + Span, + SpanOptions, + UseCaseSpan, +} from './tracing/span.decorator'; +export { TracingModule } from './tracing/tracing.module'; diff --git a/libs/observability/src/kafka/kafka-tracing.helper.ts b/libs/observability/src/kafka/kafka-tracing.helper.ts new file mode 100644 index 0000000000..d174ad25bf --- /dev/null +++ b/libs/observability/src/kafka/kafka-tracing.helper.ts @@ -0,0 +1,116 @@ +import { context, propagation, type Span, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { EachMessagePayload } from 'kafkajs'; + +export function injectTraceContext(headers: Record = {}): Record { + const activeContext = context.active(); + + propagation.inject(activeContext, headers, { + set: (carrier, key, value) => { + carrier[key] = value; + }, + }); + + return headers; +} + +export function extractTraceContext(headers: Record = {}): any { + return propagation.extract(context.active(), headers, { + get: (carrier, key) => { + const value = carrier[key]; + if (Array.isArray(value)) { + return value[0]?.toString(); + } + return value?.toString(); + }, + keys: (carrier) => Object.keys(carrier), + }); +} + +export function wrapKafkaHandler( + topicName: string, + handler: (payload: EachMessagePayload) => Promise, +): (payload: EachMessagePayload) => Promise { + return async (payload: EachMessagePayload) => { + const { topic, partition, message } = payload; + const tracer = trace.getTracer('kafka-consumer'); + + const extractedContext = extractTraceContext(message.headers || {}); + + return context.with(extractedContext, async () => { + return tracer.startActiveSpan( + `kafka.consume.${topicName}`, + { + kind: SpanKind.CONSUMER, + attributes: { + 'messaging.system': 'kafka', + 'messaging.destination': topic, + 'messaging.operation': 'receive', + 'messaging.kafka.partition': partition, + 'messaging.kafka.offset': message.offset, + 'messaging.message_id': message.key?.toString(), + }, + }, + async (span: Span) => { + try { + await handler(payload); + + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + + throw error; + } finally { + span.end(); + } + }, + ); + }); + }; +} + +export async function withProducerSpan( + topicName: string, + messageKey: string, + handler: () => Promise, +): Promise { + const tracer = trace.getTracer('kafka-producer'); + + return tracer.startActiveSpan( + `kafka.produce.${topicName}`, + { + kind: 4, + attributes: { + 'messaging.system': 'kafka', + 'messaging.destination': topicName, + 'messaging.operation': 'send', + 'messaging.message_id': messageKey, + }, + }, + async (span: Span) => { + try { + const result = await handler(); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + throw error; + } finally { + span.end(); + } + }, + ); +} + +export function getTraceHeaders(): Record { + const headers: Record = {}; + injectTraceContext(headers); + return headers; +} diff --git a/libs/observability/src/logger.service.ts b/libs/observability/src/logger.service.ts new file mode 100644 index 0000000000..e64daa3cfe --- /dev/null +++ b/libs/observability/src/logger.service.ts @@ -0,0 +1,28 @@ +import type { RequestContext } from '@app/common/types/tracing.types'; +import { Inject, Injectable } from '@nestjs/common'; +import { type ILogger, LOGGER_PORT } from './ports/logger.port'; + +@Injectable() +export class LoggerService implements ILogger { + constructor(@Inject(LOGGER_PORT) private readonly logger: ILogger) {} + + log(message: string, context?: Partial, metadata?: Record): void { + this.logger.log(message, context, metadata); + } + + error(message: string, error?: Error, context?: Partial, metadata?: Record): void { + this.logger.error(message, error, context, metadata); + } + + warn(message: string, context?: Partial, metadata?: Record): void { + this.logger.warn(message, context, metadata); + } + + debug(message: string, context?: Partial, metadata?: Record): void { + this.logger.debug(message, context, metadata); + } + + verbose(message: string, context?: Partial, metadata?: Record): void { + this.logger.verbose(message, context, metadata); + } +} diff --git a/libs/observability/src/logging/http-logger.interceptor.ts b/libs/observability/src/logging/http-logger.interceptor.ts new file mode 100644 index 0000000000..df214d7606 --- /dev/null +++ b/libs/observability/src/logging/http-logger.interceptor.ts @@ -0,0 +1,83 @@ +import { type CallHandler, type ExecutionContext, Injectable, type NestInterceptor } from '@nestjs/common'; +import type { Observable } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import type { PinoLoggerService } from './pino-logger.service'; + +@Injectable() +export class HttpLoggerInterceptor implements NestInterceptor { + constructor(private readonly logger: PinoLoggerService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const { method, url, headers, body } = request; + const startTime = Date.now(); + + this.logger.log('Incoming HTTP request', { + http: { + method, + url, + userAgent: headers['user-agent'], + contentType: headers['content-type'], + }, + request: { + body: this.sanitizeBody(body), + }, + }); + + return next.handle().pipe( + tap((data) => { + const duration = Date.now() - startTime; + + this.logger.log('HTTP request completed', { + http: { + method, + url, + statusCode: response.statusCode, + duration, + }, + response: { + size: JSON.stringify(data).length, + }, + }); + }), + catchError((error) => { + const duration = Date.now() - startTime; + + this.logger.error(`HTTP request failed: ${error.message}`, error.stack, { + http: { + method, + url, + statusCode: error.status || 500, + duration, + }, + error: { + name: error.name, + message: error.message, + }, + }); + + throw error; + }), + ); + } + + private sanitizeBody(body: any): any { + if (!body) return undefined; + + const sanitized = { ...body }; + const sensitiveFields = ['password', 'token', 'creditCard', 'ssn']; + + for (const field of sensitiveFields) { + if (sanitized[field]) { + sanitized[field] = '[REDACTED]'; + } + } + + return sanitized; + } +} diff --git a/libs/observability/src/logging/logging.module.ts b/libs/observability/src/logging/logging.module.ts new file mode 100644 index 0000000000..51687a083a --- /dev/null +++ b/libs/observability/src/logging/logging.module.ts @@ -0,0 +1,39 @@ +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import { createPinoLogger, PinoLoggerService } from './pino-logger.service'; + +@Global() +@Module({}) +export class LoggingModule { + static forRoot(serviceName: string): DynamicModule { + const loggerProvider = { + provide: PinoLoggerService, + useValue: createPinoLogger(serviceName), + }; + + return { + module: LoggingModule, + providers: [loggerProvider], + exports: [loggerProvider], + }; + } + + static forRootAsync(options: { + inject?: any[]; + useFactory: (...args: any[]) => string | Promise; + }): DynamicModule { + const loggerProvider = { + provide: PinoLoggerService, + useFactory: async (...args: any[]) => { + const serviceName = await options.useFactory(...args); + return createPinoLogger(serviceName); + }, + inject: options.inject || [], + }; + + return { + module: LoggingModule, + providers: [loggerProvider], + exports: [loggerProvider], + }; + } +} diff --git a/libs/observability/src/logging/pino-logger.service.ts b/libs/observability/src/logging/pino-logger.service.ts new file mode 100644 index 0000000000..d43e091b47 --- /dev/null +++ b/libs/observability/src/logging/pino-logger.service.ts @@ -0,0 +1,98 @@ +import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common'; +import pino from 'pino'; +import { getCurrentTraceContext } from '../tracing/otel'; + +export interface LogContext { + [key: string]: any; +} + +@Injectable() +export class PinoLoggerService implements NestLoggerService { + private logger: pino.Logger; + + constructor(private readonly serviceName: string) { + const isDevelopment = process.env.NODE_ENV !== 'production'; + + this.logger = pino({ + name: serviceName, + level: process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'), + + transport: isDevelopment + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + singleLine: false, + }, + } + : undefined, + + formatters: { + level: (label) => { + return { level: label }; + }, + }, + + timestamp: pino.stdTimeFunctions.isoTime, + + redact: { + paths: ['password', 'token', 'authorization', 'cookie', 'creditCard', 'ssn'], + remove: true, + }, + }); + } + + private enrichWithTraceContext(context?: LogContext): LogContext { + const traceContext = getCurrentTraceContext(); + + return { + service: this.serviceName, + ...context, + ...(traceContext && { + traceId: traceContext.traceId, + spanId: traceContext.spanId, + traceFlags: traceContext.traceFlags, + }), + }; + } + + log(message: string, context?: LogContext): void { + this.logger.info(this.enrichWithTraceContext(context), message); + } + + error(message: string, trace?: string, context?: LogContext): void { + this.logger.error(this.enrichWithTraceContext({ ...context, stack: trace }), message); + } + + warn(message: string, context?: LogContext): void { + this.logger.warn(this.enrichWithTraceContext(context), message); + } + + debug(message: string, context?: LogContext): void { + this.logger.debug(this.enrichWithTraceContext(context), message); + } + + verbose(message: string, context?: LogContext): void { + this.logger.trace(this.enrichWithTraceContext(context), message); + } + + fatal(message: string, context?: LogContext): void { + this.logger.fatal(this.enrichWithTraceContext(context), message); + } + + child(bindings: LogContext): PinoLoggerService { + const childLogger = new PinoLoggerService(this.serviceName); + childLogger.logger = this.logger.child(bindings); + return childLogger; + } + + getPinoLogger(): pino.Logger { + return this.logger; + } +} + +export function createPinoLogger(serviceName: string): PinoLoggerService { + return new PinoLoggerService(serviceName); +} diff --git a/libs/observability/src/observability.module.ts b/libs/observability/src/observability.module.ts new file mode 100644 index 0000000000..6cf3ca9bb7 --- /dev/null +++ b/libs/observability/src/observability.module.ts @@ -0,0 +1,66 @@ +import { AllExceptionsFilter } from '@app/common'; +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; +import { CompositeLoggerAdapter } from './adapters/composite-logger.adapter'; +import { ConsoleLoggerAdapter } from './adapters/console-logger.adapter'; +import { SentryLoggerAdapter } from './adapters/sentry-logger.adapter'; +import { LoggerService } from './logger.service'; +import { type ILogger, LOGGER_PORT } from './ports/logger.port'; + +export interface ObservabilityModuleOptions { + serviceName: string; + enableSentry?: boolean; + sentryDsn?: string; + environment?: string; +} + +@Global() +@Module({}) +export class ObservabilityModule { + static forRoot(options: ObservabilityModuleOptions): DynamicModule { + const loggers: ILogger[] = []; + const imports: any[] = []; + + loggers.push(new ConsoleLoggerAdapter(options.serviceName)); + + if (options.enableSentry && options.sentryDsn) { + loggers.push(new SentryLoggerAdapter(options.serviceName)); + + imports.push(SentryModule.forRoot()); + } + + const compositeLogger = loggers.length > 1 ? new CompositeLoggerAdapter(loggers) : loggers[0]; + + const providers: any[] = [ + { + provide: LOGGER_PORT, + useValue: compositeLogger, + }, + LoggerService, + ]; + + if (options.enableSentry && options.sentryDsn) { + providers.push({ + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }); + } else { + providers.push({ + provide: APP_FILTER, + inject: [LoggerService], + useFactory: (logger: LoggerService) => { + return new AllExceptionsFilter(logger); + }, + }); + } + + return { + module: ObservabilityModule, + global: true, + imports, + providers, + exports: [LOGGER_PORT, LoggerService], + }; + } +} diff --git a/libs/observability/src/ports/logger.port.ts b/libs/observability/src/ports/logger.port.ts new file mode 100644 index 0000000000..cb3d5deb83 --- /dev/null +++ b/libs/observability/src/ports/logger.port.ts @@ -0,0 +1,11 @@ +import type { RequestContext } from '@app/common/types/tracing.types'; + +export interface ILogger { + log(message: string, context?: Partial, metadata?: Record): void; + error(message: string, error?: Error, context?: Partial, metadata?: Record): void; + warn(message: string, context?: Partial, metadata?: Record): void; + debug(message: string, context?: Partial, metadata?: Record): void; + verbose(message: string, context?: Partial, metadata?: Record): void; +} + +export const LOGGER_PORT = Symbol.for('LOGGER_PORT'); diff --git a/libs/observability/src/tracing/otel.ts b/libs/observability/src/tracing/otel.ts new file mode 100644 index 0000000000..69f04b315d --- /dev/null +++ b/libs/observability/src/tracing/otel.ts @@ -0,0 +1,146 @@ +import { trace } from '@opentelemetry/api'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; + +export interface TelemetryConfig { + serviceName: string; + serviceVersion?: string; + otlpEndpoint?: string; + environment?: string; + sampling?: number; + enabled?: boolean; +} + +let sdk: NodeSDK | null = null; + +export function initializeTelemetry(config: TelemetryConfig): NodeSDK | null { + if (sdk) { + console.warn('⚠️ OpenTelemetry SDK already initialized'); + return sdk; + } + + const { + serviceName, + serviceVersion = '1.0.0', + otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318', + environment = process.env.NODE_ENV || 'development', + sampling = parseFloat(process.env.OTEL_SAMPLING_RATIO || '1.0'), + enabled = process.env.OTEL_ENABLED !== 'false', + } = config; + + if (!enabled) { + console.log('📊 OpenTelemetry is disabled'); + return null; + } + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: serviceName, + [ATTR_SERVICE_VERSION]: serviceVersion, + 'deployment.environment': environment, + }); + + const traceExporter = new OTLPTraceExporter({ + url: `${otlpEndpoint}/v1/traces`, + headers: {}, + }); + + const metricExporter = new OTLPMetricExporter({ + url: `${otlpEndpoint}/v1/metrics`, + headers: {}, + }); + + sdk = new NodeSDK({ + resource, + traceExporter, + metricReader: new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 60000, + }), + instrumentations: [ + getNodeAutoInstrumentations({ + // Auto-instrumenta HTTP, Express, Kafka, PostgreSQL, etc. + '@opentelemetry/instrumentation-http': { + ignoreIncomingRequestHook: (req) => { + const ignorePaths = ['/health', '/metrics', '/ready']; + return ignorePaths.some((path) => req.url?.startsWith(path)); + }, + requestHook: (span, request) => { + if (request.socket?.remoteAddress) { + span.setAttribute('http.client_ip', request.socket.remoteAddress); + } + }, + }, + // Deshabilitar instrumentaciones que no necesites + '@opentelemetry/instrumentation-fs': { + enabled: false, + }, + }), + ], + + sampler: + sampling < 1.0 + ? { + shouldSample: () => { + return Math.random() < sampling ? { decision: 1 } : { decision: 0 }; + }, + toString: () => `CustomSampler{${sampling}}`, + } + : undefined, + }); + + sdk.start(); + + console.log(`🚀 OpenTelemetry initialized for service: ${serviceName}`); + console.log(`📡 Exporting traces to: ${otlpEndpoint}`); + console.log(`🎲 Sampling ratio: ${sampling * 100}%`); + + const shutdown = async () => { + try { + if (sdk) { + await sdk.shutdown(); + console.log('✅ OpenTelemetry SDK shut down successfully'); + } + } catch (err) { + console.error('❌ Error shutting down OpenTelemetry SDK:', err); + } + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + return sdk; +} + +export function getTracer(name: string) { + return trace.getTracer(name); +} + +export function getCurrentTraceContext(): { + traceId: string; + spanId: string; + traceFlags: string; +} | null { + const span = trace.getActiveSpan(); + if (!span) { + return null; + } + + const spanContext = span.spanContext(); + return { + traceId: spanContext.traceId, + spanId: spanContext.spanId, + traceFlags: spanContext.traceFlags.toString(16).padStart(2, '0'), + }; +} + +export async function shutdownTelemetry(): Promise { + if (sdk) { + await sdk.shutdown(); + sdk = null; + } +} diff --git a/libs/observability/src/tracing/span.decorator.ts b/libs/observability/src/tracing/span.decorator.ts new file mode 100644 index 0000000000..035058e82c --- /dev/null +++ b/libs/observability/src/tracing/span.decorator.ts @@ -0,0 +1,93 @@ +import { type Span as OtelSpan, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; + +export interface SpanOptions { + name?: string; + kind?: number; + attributes?: Record; + recordException?: boolean; +} + +export function Span(nameOrOptions?: string | SpanOptions) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + const className = target.constructor.name; + + descriptor.value = async function (...args: any[]) { + const options: SpanOptions = + typeof nameOrOptions === 'string' ? { name: nameOrOptions } : nameOrOptions || {}; + + const spanName = options.name || `${className}.${propertyKey}`; + const tracer = trace.getTracer('app-tracer'); + + return tracer.startActiveSpan( + spanName, + { + kind: options.kind, + attributes: { + 'code.function': propertyKey, + 'code.class': className, + ...options.attributes, + }, + }, + async (span: OtelSpan) => { + try { + const result = await originalMethod.apply(this, args); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + if (options.recordException !== false) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + } + throw error; + } finally { + span.end(); + } + }, + ); + }; + + return descriptor; + }; +} + +export function KafkaConsumerSpan(topicName: string) { + return Span({ + name: `kafka.consume.${topicName}`, + kind: SpanKind.CONSUMER, + attributes: { + 'messaging.system': 'kafka', + 'messaging.destination': topicName, + 'messaging.operation': 'receive', + }, + recordException: true, + }); +} + +export function RepositorySpan(operationName: string, entityName?: string) { + return Span({ + name: `db.${operationName}`, + kind: SpanKind.CLIENT, + attributes: { + 'db.system': 'postgresql', + 'db.operation': operationName, + ...(entityName && { 'db.entity': entityName }), + }, + recordException: true, + }); +} + +export function UseCaseSpan(useCaseName: string) { + return Span({ + name: `usecase.${useCaseName}`, + kind: SpanKind.INTERNAL, + attributes: { + 'code.layer': 'application', + 'code.type': 'usecase', + }, + recordException: true, + }); +} diff --git a/libs/observability/src/tracing/tracing.module.ts b/libs/observability/src/tracing/tracing.module.ts new file mode 100644 index 0000000000..adad3c003a --- /dev/null +++ b/libs/observability/src/tracing/tracing.module.ts @@ -0,0 +1,36 @@ +import { type DynamicModule, Global, Module } from '@nestjs/common'; +import type { TelemetryConfig } from './otel'; + +@Global() +@Module({}) +export class TracingModule { + static forRoot(config: TelemetryConfig): DynamicModule { + return { + module: TracingModule, + providers: [ + { + provide: 'TELEMETRY_CONFIG', + useValue: config, + }, + ], + exports: ['TELEMETRY_CONFIG'], + }; + } + + static forRootAsync(options: { + inject?: any[]; + useFactory: (...args: any[]) => TelemetryConfig | Promise; + }): DynamicModule { + return { + module: TracingModule, + providers: [ + { + provide: 'TELEMETRY_CONFIG', + useFactory: options.useFactory, + inject: options.inject || [], + }, + ], + exports: ['TELEMETRY_CONFIG'], + }; + } +} diff --git a/libs/observability/tsconfig.lib.json b/libs/observability/tsconfig.lib.json new file mode 100644 index 0000000000..42c1e8f41c --- /dev/null +++ b/libs/observability/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/observability", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000000..35e08d4f62 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "apps", + "monorepo": true, + "root": "apps", + "compilerOptions": { + "deleteOutDir": true, + "webpack": false, + "tsConfigPath": "apps/transaction-service/tsconfig.app.json" + }, + "projects": { + "transaction-service": { + "type": "application", + "root": "apps/transaction-service", + "entryFile": "main", + "sourceRoot": "apps/transaction-service/src", + "compilerOptions": { + "tsConfigPath": "apps/transaction-service/tsconfig.app.json" + } + }, + "anti-fraud-service": { + "type": "application", + "root": "apps/anti-fraud-service", + "entryFile": "main", + "sourceRoot": "apps/anti-fraud-service/src", + "compilerOptions": { + "tsConfigPath": "apps/anti-fraud-service/tsconfig.app.json" + } + }, + "common": { + "type": "library", + "root": "libs/common", + "entryFile": "index", + "sourceRoot": "libs/common/src", + "compilerOptions": { + "tsConfigPath": "libs/common/tsconfig.lib.json" + } + }, + "observability": { + "type": "library", + "root": "libs/observability", + "entryFile": "index", + "sourceRoot": "libs/observability/src", + "compilerOptions": { + "tsConfigPath": "libs/observability/tsconfig.lib.json" + } + } + } +} diff --git a/observability/grafana/provisioning/dashboards/dashboard.yaml b/observability/grafana/provisioning/dashboards/dashboard.yaml new file mode 100644 index 0000000000..8a5e7b7d98 --- /dev/null +++ b/observability/grafana/provisioning/dashboards/dashboard.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/observability/grafana/provisioning/datasources/datasources-minimal.yaml b/observability/grafana/provisioning/datasources/datasources-minimal.yaml new file mode 100644 index 0000000000..2699efef3e --- /dev/null +++ b/observability/grafana/provisioning/datasources/datasources-minimal.yaml @@ -0,0 +1,20 @@ +apiVersion: 1 + +datasources: + # Tempo - Distributed Tracing + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + isDefault: true + editable: true + jsonData: + nodeGraph: + enabled: true + search: + hide: false + lokiSearch: + datasourceUid: '' + serviceMap: + datasourceUid: '' diff --git a/observability/grafana/provisioning/datasources/datasources.yaml b/observability/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000000..d93b772586 --- /dev/null +++ b/observability/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,55 @@ +apiVersion: 1 + +datasources: + # Tempo - Distributed Tracing + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + jsonData: + tracesToLogs: + datasourceUid: 'loki' + tags: ['traceId', 'spanId'] + mappedTags: [{ key: 'service', value: 'service' }] + mapTagNamesEnabled: true + spanStartTimeShift: '-1h' + spanEndTimeShift: '1h' + tracesToMetrics: + datasourceUid: 'prometheus' + tags: [{ key: 'service.name', value: 'service' }] + queries: + - name: 'Sample query' + query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m]))' + serviceMap: + datasourceUid: 'prometheus' + nodeGraph: + enabled: true + + # Prometheus - Metrics + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + editable: true + jsonData: + httpMethod: POST + exemplarTraceIdDestinations: + - name: traceId + datasourceUid: tempo + + # Loki - Logs + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: "traceId=(\\w+)" + name: TraceID + url: '$${__value.raw}' diff --git a/observability/tempo/tempo.yaml b/observability/tempo/tempo.yaml new file mode 100644 index 0000000000..d239e882a9 --- /dev/null +++ b/observability/tempo/tempo.yaml @@ -0,0 +1,31 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +ingester: + max_block_duration: 5m + +compactor: + compaction: + block_retention: 48h + +storage: + trace: + backend: local + wal: + path: /tmp/tempo/wal + local: + path: /tmp/tempo/blocks + +overrides: + defaults: + metrics_generator: + processors: [service-graphs, span-metrics] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..a9b77d64ab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11716 @@ +{ + "name": "app-nodejs-codechallenge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app-nodejs-codechallenge", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@nestjs/common": "^11.1.11", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.11", + "@nestjs/microservices": "^11.1.11", + "@nestjs/platform-express": "^11.1.11", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/auto-instrumentations-node": "0.52.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.54.2", + "@opentelemetry/exporter-trace-otlp-http": "0.54.2", + "@opentelemetry/instrumentation-http": "0.54.2", + "@opentelemetry/instrumentation-kafkajs": "^0.3.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-node": "0.54.2", + "@opentelemetry/semantic-conventions": "1.27.0", + "@sentry/nestjs": "^10.32.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "drizzle-orm": "^0.45.1", + "ioredis": "^5.8.2", + "kafkajs": "^2.2.4", + "pino": "^9.5.0", + "pino-http": "^10.3.0", + "pino-pretty": "^13.0.0", + "postgres": "^3.4.7", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@biomejs/biome": "2.3.10", + "@nestjs/cli": "^11.0.14", + "@nestjs/testing": "^11.1.11", + "@types/express": "^5.0.6", + "@types/jest": "^29.5.12", + "@types/node": "^25.0.3", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.8", + "jest": "^29.7.0", + "nodemon": "^3.1.11", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.19", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.19", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.19", + "@angular-devkit/schematics": "19.2.19", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.8.2", + "license": "Apache-2.0" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.3.1", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.8.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@biomejs/biome": { + "version": "2.3.10", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.10", + "@biomejs/cli-darwin-x64": "2.3.10", + "@biomejs/cli-linux-arm64": "2.3.10", + "@biomejs/cli-linux-arm64-musl": "2.3.10", + "@biomejs/cli-linux-x64": "2.3.10", + "@biomejs/cli-linux-x64-musl": "2.3.10", + "@biomejs/cli-win32-arm64": "2.3.10", + "@biomejs/cli-win32-x64": "2.3.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.10.tgz", + "integrity": "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.10.tgz", + "integrity": "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.10.tgz", + "integrity": "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.10.tgz", + "integrity": "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.10.tgz", + "integrity": "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.10.tgz", + "integrity": "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.10.tgz", + "integrity": "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.10", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/jest-worker": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.19", + "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/schematics-cli": "19.2.19", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "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.103.0", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.11", + "license": "MIT", + "dependencies": { + "file-type": "21.2.0", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.7", + "dotenv-expand": "12.0.1", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.11", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/microservices": { + "version": "11.1.11", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "amqp-connection-manager": "*", + "amqplib": "*", + "cache-manager": "*", + "ioredis": "*", + "kafkajs": "*", + "mqtt": "*", + "nats": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + }, + "amqp-connection-manager": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "cache-manager": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "kafkajs": { + "optional": true + }, + "mqtt": { + "optional": true + }, + "nats": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.11", + "license": "MIT", + "dependencies": { + "cors": "2.8.5", + "express": "5.2.1", + "multer": "2.0.2", + "path-to-regexp": "8.3.0", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.17", + "@angular-devkit/schematics": "19.2.17", + "comment-json": "4.4.1", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "typescript": ">=4.8.2" + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.17", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.54.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.54.2.tgz", + "integrity": "sha512-4MTVwwmLgUh5QrJnZpYo6YRO5IBLAggf2h8gWDblwRagDStY13aEvt7gGk3jewrMaPlHiF83fENhIx0HO97/cQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node": { + "version": "0.52.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/instrumentation-amqplib": "^0.43.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.47.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.46.0", + "@opentelemetry/instrumentation-bunyan": "^0.42.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.42.0", + "@opentelemetry/instrumentation-connect": "^0.40.0", + "@opentelemetry/instrumentation-cucumber": "^0.10.0", + "@opentelemetry/instrumentation-dataloader": "^0.13.0", + "@opentelemetry/instrumentation-dns": "^0.40.0", + "@opentelemetry/instrumentation-express": "^0.44.0", + "@opentelemetry/instrumentation-fastify": "^0.41.0", + "@opentelemetry/instrumentation-fs": "^0.16.0", + "@opentelemetry/instrumentation-generic-pool": "^0.40.0", + "@opentelemetry/instrumentation-graphql": "^0.44.0", + "@opentelemetry/instrumentation-grpc": "^0.54.0", + "@opentelemetry/instrumentation-hapi": "^0.42.0", + "@opentelemetry/instrumentation-http": "^0.54.0", + "@opentelemetry/instrumentation-ioredis": "^0.44.0", + "@opentelemetry/instrumentation-kafkajs": "^0.4.0", + "@opentelemetry/instrumentation-knex": "^0.41.0", + "@opentelemetry/instrumentation-koa": "^0.44.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.41.0", + "@opentelemetry/instrumentation-memcached": "^0.40.0", + "@opentelemetry/instrumentation-mongodb": "^0.48.0", + "@opentelemetry/instrumentation-mongoose": "^0.43.0", + "@opentelemetry/instrumentation-mysql": "^0.42.0", + "@opentelemetry/instrumentation-mysql2": "^0.42.1", + "@opentelemetry/instrumentation-nestjs-core": "^0.41.0", + "@opentelemetry/instrumentation-net": "^0.40.0", + "@opentelemetry/instrumentation-pg": "^0.47.1", + "@opentelemetry/instrumentation-pino": "^0.43.0", + "@opentelemetry/instrumentation-redis": "^0.43.0", + "@opentelemetry/instrumentation-redis-4": "^0.43.0", + "@opentelemetry/instrumentation-restify": "^0.42.0", + "@opentelemetry/instrumentation-router": "^0.41.0", + "@opentelemetry/instrumentation-socket.io": "^0.43.0", + "@opentelemetry/instrumentation-tedious": "^0.15.0", + "@opentelemetry/instrumentation-undici": "^0.7.1", + "@opentelemetry/instrumentation-winston": "^0.41.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.4", + "@opentelemetry/resource-detector-aws": "^1.7.0", + "@opentelemetry/resource-detector-azure": "^0.2.12", + "@opentelemetry/resource-detector-container": "^0.5.0", + "@opentelemetry/resource-detector-gcp": "^0.29.13", + "@opentelemetry/resources": "^1.24.0", + "@opentelemetry/sdk-node": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.4.1" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-amqplib/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.40.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.36" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-connect/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.13.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-express": { + "version": "0.44.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-express/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.16.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.40.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.44.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.42.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-hapi/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.44.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-ioredis/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.4.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-kafkajs/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-knex/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.44.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-koa/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.48.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mongodb/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mongoose/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.42.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mysql/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.42.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-mysql2/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-nestjs-core": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-nestjs-core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.47.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0", + "@opentelemetry/sql-common": "^0.40.1", + "@types/pg": "8.6.1", + "@types/pg-pool": "2.0.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-redis/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.15.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-tedious/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.7.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@types/connect": { + "version": "3.4.36", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@types/mysql": { + "version": "2.15.26", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@types/pg": { + "version": "8.6.1", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.2.0", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/sdk-logs": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/sdk-logs": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.2", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.54.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.54.2.tgz", + "integrity": "sha512-go6zpOVoZVztT9r1aPd79Fr3OWiD4N24bCPJsIKkBses8oyFo12F/Ew3UBTdIu6hsW4HC4MVEJygG6TEyJI/lg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.55.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-lambda": { + "version": "0.47.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "8.10.143" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-sdk": { + "version": "0.46.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/propagation-utils": "^0.30.12", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-sdk/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-sdk/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-sdk/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-bunyan": { + "version": "0.42.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@types/bunyan": "1.8.9" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-cassandra-driver": { + "version": "0.42.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.52.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-cucumber": { + "version": "0.10.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.26.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dns": { + "version": "0.40.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.57.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fastify": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fastify/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fastify/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-fastify/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.28.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.52.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.56.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-grpc": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "0.54.2", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.55.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/instrumentation": "0.54.2", + "@opentelemetry/semantic-conventions": "1.27.0", + "forwarded-parse": "2.1.2", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.56.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/redis-common": "^0.38.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.3.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.53.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.57.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.53.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-memcached": { + "version": "0.40.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/memcached": "^2.2.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.61.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.55.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.54.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.55.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-nestjs-core": { + "version": "0.55.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-net": { + "version": "0.40.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.61.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.6" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/core": "^1.25.0", + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.57.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis-4": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis-4/node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-restify": { + "version": "0.42.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-restify/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-restify/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-restify/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-router": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-socket.io": { + "version": "0.43.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.19.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/instrumentation-winston": { + "version": "0.41.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation/node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-transformer": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.2", + "@opentelemetry/otlp-transformer": "0.54.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.2", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagation-utils": { + "version": "0.30.16", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { + "version": "0.29.7", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-alibaba-cloud/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-alibaba-cloud/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-alibaba-cloud/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-aws": { + "version": "1.12.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-azure": { + "version": "0.2.12", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.25.1", + "@opentelemetry/resources": "^1.10.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-azure/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-azure/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-azure/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-container": { + "version": "0.5.3", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-container/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-container/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-container/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp": { + "version": "0.29.13", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp/node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.27.0.tgz", + "integrity": "sha512-JzWgzlutoXCydhHWIbLg+r76m+m3ncqvkCcsswXAQ4gqKS+LOHKhq+t6fx1zNytvLuaOUBur7EvWxECc4jPQKg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.54.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.54.2", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.54.2", + "@opentelemetry/exporter-logs-otlp-http": "0.54.2", + "@opentelemetry/exporter-logs-otlp-proto": "0.54.2", + "@opentelemetry/exporter-trace-otlp-grpc": "0.54.2", + "@opentelemetry/exporter-trace-otlp-http": "0.54.2", + "@opentelemetry/exporter-trace-otlp-proto": "0.54.2", + "@opentelemetry/exporter-zipkin": "1.27.0", + "@opentelemetry/instrumentation": "0.54.2", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.2", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/sdk-trace-node": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.27.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/propagator-b3": "1.27.0", + "@opentelemetry/propagator-jaeger": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "1.27.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@prisma/instrumentation": { + "version": "6.19.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": ">=0.52.0 <1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@sentry/core": { + "version": "10.32.1", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/nestjs": { + "version": "10.32.1", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-nestjs-core": "0.55.0", + "@opentelemetry/semantic-conventions": "^1.37.0", + "@sentry/core": "10.32.1", + "@sentry/node": "10.32.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@sentry/node": { + "version": "10.32.1", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-amqplib": "0.55.0", + "@opentelemetry/instrumentation-connect": "0.52.0", + "@opentelemetry/instrumentation-dataloader": "0.26.0", + "@opentelemetry/instrumentation-express": "0.57.0", + "@opentelemetry/instrumentation-fs": "0.28.0", + "@opentelemetry/instrumentation-generic-pool": "0.52.0", + "@opentelemetry/instrumentation-graphql": "0.56.0", + "@opentelemetry/instrumentation-hapi": "0.55.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation-ioredis": "0.56.0", + "@opentelemetry/instrumentation-kafkajs": "0.18.0", + "@opentelemetry/instrumentation-knex": "0.53.0", + "@opentelemetry/instrumentation-koa": "0.57.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.53.0", + "@opentelemetry/instrumentation-mongodb": "0.61.0", + "@opentelemetry/instrumentation-mongoose": "0.55.0", + "@opentelemetry/instrumentation-mysql": "0.54.0", + "@opentelemetry/instrumentation-mysql2": "0.55.0", + "@opentelemetry/instrumentation-pg": "0.61.0", + "@opentelemetry/instrumentation-redis": "0.57.0", + "@opentelemetry/instrumentation-tedious": "0.27.0", + "@opentelemetry/instrumentation-undici": "0.19.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0", + "@prisma/instrumentation": "6.19.0", + "@sentry/core": "10.32.1", + "@sentry/node-core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", + "import-in-the-middle": "^2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.32.1", + "license": "MIT", + "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.3.1", + "@sentry/core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", + "import-in-the-middle": "^2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + } + }, + "node_modules/@sentry/node/node_modules/@opentelemetry/instrumentation-http": { + "version": "0.208.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/instrumentation": "0.208.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@sentry/node/node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.18.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@sentry/node/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@sentry/node/node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@sentry/node/node_modules/minimatch": { + "version": "9.0.5", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.32.1", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.143", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bunyan": { + "version": "1.8.9", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/memcached": { + "version": "2.2.10", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.3", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.1", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/exit": { + "version": "0.1.2", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fast-copy": { + "version": "4.0.2", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-type": { + "version": "21.2.0", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "13.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-in-the-middle": { + "version": "2.0.1", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.8.2", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-haste-map/node_modules/jest-worker": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/jest-worker": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runner/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kafkajs": { + "version": "2.2.4", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.33", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "license": "BSD-2-Clause" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.4", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "10.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.16", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.103.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..9a606c96e3 --- /dev/null +++ b/package.json @@ -0,0 +1,101 @@ +{ + "name": "app-nodejs-codechallenge", + "version": "1.0.0", + "description": "Our code challenge will let you marvel us with your Jedi coding skills :smile:.", + "main": "index.js", + "scripts": { + "build": "nest build", + "build:transaction": "nest build transaction-service", + "start:anti-fraud": "nest start anti-fraud-service", + "start:transaction:dev": "nest start transaction-service --watch", + "start:anti-fraud:dev": "nest start anti-fraud-service --watch", + "start:dev": "concurrently \"npm run start:transaction:dev\" \"npm run start:anti-fraud:dev\"", + "db:generate": "drizzle-kit generate --config=config/database/drizzle.config.ts", + "db:migrate": "drizzle-kit migrate --config=config/database/drizzle.config.ts", + "db:push": "drizzle-kit push --config=config/database/drizzle.config.ts", + "db:studio": "drizzle-kit studio --config=config/database/drizzle.config.ts", + "seed:fraud-rules": "ts-node -r tsconfig-paths/register -P tsconfig.json apps/anti-fraud-service/scripts/seed-default-rules.ts", + "test": "jest --config=config/jest.config.js", + "test:watch": "jest --config=config/jest.config.js --watch", + "test:cov": "jest --config=config/jest.config.js --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --config=config/jest.config.js --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:api": "bash test/api/test-transaction-api.sh", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format .", + "format:fix": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --write .", + "ci": "biome ci ." + }, + "repository": { + "type": "git", + "url": "git+https://github.com/yaperos/app-nodejs-codechallenge.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/yaperos/app-nodejs-codechallenge/issues" + }, + "homepage": "https://github.com/yaperos/app-nodejs-codechallenge#readme", + "dependencies": { + "@nestjs/common": "^11.1.11", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.11", + "@nestjs/microservices": "^11.1.11", + "@nestjs/platform-express": "^11.1.11", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/auto-instrumentations-node": "0.52.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.54.2", + "@opentelemetry/exporter-trace-otlp-http": "0.54.2", + "@opentelemetry/instrumentation-http": "0.54.2", + "@opentelemetry/instrumentation-kafkajs": "^0.3.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-node": "0.54.2", + "@opentelemetry/semantic-conventions": "1.27.0", + "@sentry/nestjs": "^10.32.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "drizzle-orm": "^0.45.1", + "ioredis": "^5.8.2", + "kafkajs": "^2.2.4", + "pino": "^9.5.0", + "pino-http": "^10.3.0", + "pino-pretty": "^13.0.0", + "postgres": "^3.4.7", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@biomejs/biome": "2.3.10", + "@nestjs/cli": "^11.0.14", + "@nestjs/testing": "^11.1.11", + "@types/express": "^5.0.6", + "@types/jest": "^29.5.12", + "@types/node": "^25.0.3", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.8", + "jest": "^29.7.0", + "nodemon": "^3.1.11", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.9.3" + }, + "overrides": { + "@opentelemetry/api": "1.9.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-node": "0.54.2", + "@opentelemetry/semantic-conventions": "1.27.0", + "@opentelemetry/instrumentation": "0.54.2" + } +} diff --git a/test/api/test-transaction-api.sh b/test/api/test-transaction-api.sh new file mode 100644 index 0000000000..53de04d12e --- /dev/null +++ b/test/api/test-transaction-api.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -e + +BASE_URL="${BASE_URL:-http://localhost:3000}" +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}======================================${NC}" +echo -e "${YELLOW}Testing Transaction Service API${NC}" +echo -e "${YELLOW}Base URL: $BASE_URL${NC}" +echo -e "${YELLOW}======================================${NC}\n" + +echo -e "${YELLOW}[1/3] Creating a valid transaction (amount: 500)...${NC}" +CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/transactions" \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "123e4567-e89b-12d3-a456-426614174000", + "accountExternalIdCredit": "123e4567-e89b-12d3-a456-426614174001", + "tranferTypeId": 1, + "value": 500 + }') + +HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) +RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | head -n -1) + +if [ "$HTTP_CODE" = "201" ]; then + echo -e "${GREEN}✓ Transaction created successfully${NC}" + echo "$RESPONSE_BODY" | node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync(0, 'utf-8')), null, 2))" + TRANSACTION_ID=$(echo "$RESPONSE_BODY" | node -e "console.log(JSON.parse(require('fs').readFileSync(0, 'utf-8')).transactionExternalId)") + echo -e "${GREEN}Transaction ID: $TRANSACTION_ID${NC}\n" +else + echo -e "${RED}✗ Failed to create transaction (HTTP $HTTP_CODE)${NC}" + echo "$RESPONSE_BODY" + exit 1 +fi + +echo -e "${YELLOW}[2/3] Getting transaction by ID...${NC}" +GET_RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/transactions/$TRANSACTION_ID") +HTTP_CODE=$(echo "$GET_RESPONSE" | tail -n1) +RESPONSE_BODY=$(echo "$GET_RESPONSE" | head -n -1) + +if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Transaction retrieved successfully${NC}" + echo "$RESPONSE_BODY" | node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync(0, 'utf-8')), null, 2))" + echo "" +else + echo -e "${RED}✗ Failed to get transaction (HTTP $HTTP_CODE)${NC}" + echo "$RESPONSE_BODY" + exit 1 +fi + +echo -e "${YELLOW}[3/3] Creating high-value transaction (should be flagged by anti-fraud)...${NC}" +CREATE_HIGH_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/transactions" \ + -H "Content-Type: application/json" \ + -d '{ + "accountExternalIdDebit": "123e4567-e89b-12d3-a456-426614174002", + "accountExternalIdCredit": "123e4567-e89b-12d3-a456-426614174003", + "tranferTypeId": 1, + "value": 1500 + }') + +HTTP_CODE=$(echo "$CREATE_HIGH_RESPONSE" | tail -n1) +RESPONSE_BODY=$(echo "$CREATE_HIGH_RESPONSE" | head -n -1) + +if [ "$HTTP_CODE" = "201" ]; then + echo -e "${GREEN}✓ High-value transaction created${NC}" + echo "$RESPONSE_BODY" | node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync(0, 'utf-8')), null, 2))" + HIGH_TRANSACTION_ID=$(echo "$RESPONSE_BODY" | node -e "console.log(JSON.parse(require('fs').readFileSync(0, 'utf-8')).transactionExternalId)") + echo -e "${YELLOW}Note: This transaction should be processed by anti-fraud service${NC}" + echo -e "${YELLOW}Check anti-fraud logs for validation results${NC}\n" +else + echo -e "${RED}✗ Failed to create high-value transaction (HTTP $HTTP_CODE)${NC}" + echo "$RESPONSE_BODY" + exit 1 +fi + +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}All Transaction API tests passed! ✓${NC}" +echo -e "${GREEN}======================================${NC}" diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000000..bd1e640913 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +beforeAll(async () => { +}); + +afterAll(async () => { +}); + +jest.mock('@nestjs/config', () => ({ + ConfigService: jest.fn().mockImplementation(() => ({ + get: jest.fn((key: string) => { + const mockValues: Record = { + TRANSACTION_DB_URL: 'postgresql://test:test@localhost:5432/test', + ANTIFRAUD_DB_URL: 'postgresql://test:test@localhost:5432/test', + KAFKA_BROKER: 'localhost:9092', + OTEL_ENABLED: 'false', + REDIS_URL: 'redis://localhost:6379', + }; + return mockValues[key]; + }), + })), +})); + +jest.mock('kafkajs'); +jest.mock('drizzle-orm'); +jest.mock('ioredis'); + +jest.mock('pino', () => ({ + __esModule: true, + default: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + child: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + })), + })), +})); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..70d3e87730 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictFunctionTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@app/common": ["./libs/common/src"], + "@app/common/*": ["./libs/common/src/*"], + "@app/observability": ["./libs/observability/src"], + "@app/observability/*": ["./libs/observability/src/*"], + "@domain/*": ["./apps/transaction-service/src/domain/*"], + "@application/*": ["./apps/transaction-service/src/application/*"], + "@infrastructure/*": ["./apps/transaction-service/src/infrastructure/*"], + "@presentation/*": ["./apps/transaction-service/src/presentation/*"] + } + }, + "include": ["apps/**/*", "libs/**/*"], + "exclude": ["node_modules", "dist"] +}