From 61905d972081efa491b281d635befa2350320230 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Thu, 20 Aug 2026 11:59:57 +0100 Subject: [PATCH] Search & Indexing (Full-text + Structured) --- .eslintrc.js | 3 +- src/app.module.ts | 2 + .../oracle/dto/payload-response.dto.ts | 82 ++++-- src/blockchain/oracle/dto/sign-payload.dto.ts | 6 +- src/blockchain/oracle/oracle.controller.ts | 206 ++++++++++++-- src/common/database/database.config.ts | 7 +- src/common/database/database.module.ts | 12 +- src/common/database/database.service.spec.ts | 8 +- src/common/database/database.service.ts | 26 +- .../environments/development.config.ts | 13 +- .../environments/production.config.ts | 10 +- .../health/connection-monitor.service.spec.ts | 29 +- .../health/connection-monitor.service.ts | 11 +- .../database/health/health-check.service.ts | 7 +- .../migrations/migration.service.spec.ts | 4 +- .../database/migrations/migration.service.ts | 8 +- .../observability/slow-query.logger.ts | 6 +- .../query-builder.service.spec.ts | 19 +- .../query-builder/query-builder.service.ts | 70 +++-- .../repositories/audit-log.repository.spec.ts | 20 +- .../repositories/base.repository.spec.ts | 8 +- .../database/repositories/base.repository.ts | 8 +- .../database/retry/retry.service.spec.ts | 46 +++- src/common/database/retry/retry.service.ts | 15 +- .../strategies/snake-naming.strategy.spec.ts | 20 +- .../strategies/snake-naming.strategy.ts | 98 +++++-- src/config/logger.ts | 2 +- src/core/auth/challenge.service.spec.ts | 2 +- src/core/auth/token-blacklist.service.spec.ts | 6 +- src/core/auth/wallet-auth.service.spec.ts | 4 +- src/core/user/admin-role.controller.spec.ts | 4 +- src/core/user/admin-role.controller.ts | 4 +- src/core/user/role-seeder.service.ts | 4 +- .../services/reconnection.service.ts | 7 +- .../websocket/websocket.stress.spec.ts | 11 +- src/email/dto/send-email.dto.ts | 60 ++++- src/email/email.controller.ts | 96 +++++-- src/email/email.module.ts | 18 +- src/email/email.service.spec.ts | 140 ++++++++-- src/email/email.service.ts | 117 ++++++-- src/email/entities/email-log.entity.ts | 31 ++- .../interfaces/email-provider.interface.ts | 7 +- .../providers/sendgrid-email.provider.ts | 25 +- src/email/providers/ses-email.provider.ts | 31 ++- src/email/providers/smtp-email.provider.ts | 42 ++- src/email/services/email-processor.service.ts | 2 +- src/email/services/email-queue.service.ts | 105 ++++++-- src/email/services/template-engine.service.ts | 145 +++++++--- src/growth/alerts/alerts.module.ts | 7 +- .../services/alert-dispatcher.service.ts | 12 +- src/investment/portfolio/dto/backtest.dto.ts | 47 +++- .../portfolio/dto/optimization.dto.ts | 59 +++- .../portfolio/dto/portfolio-asset.dto.ts | 42 ++- src/investment/portfolio/dto/portfolio.dto.ts | 2 +- .../portfolio/dto/rebalancing.dto.ts | 46 +++- .../portfolio/dto/risk-profile.dto.ts | 81 ++++-- .../portfolio-management.controller.ts | 5 +- .../risk-management/dto/risk.dto.ts | 10 +- src/logging/cloudwatch.transport.ts | 28 +- src/logging/elk.transport.ts | 4 +- src/logging/external-transports.spec.ts | 11 +- src/logging/http-logging.middleware.spec.ts | 11 +- src/logging/http-logging.middleware.ts | 11 +- src/logging/index.ts | 11 +- src/logging/logger.module.ts | 18 +- src/logging/logger.service.spec.ts | 10 +- src/logging/logger.service.ts | 35 ++- src/logging/performance.interceptor.spec.ts | 36 ++- src/logging/sanitize.util.spec.ts | 5 +- src/logging/sanitize.util.ts | 24 +- src/logging/winston.config.ts | 30 ++- src/observability/profiling.service.ts | 13 +- src/profiling/profiling.service.ts | 2 +- src/search/search.controller.ts | 17 ++ src/search/search.module.ts | 15 ++ src/search/search.service.ts | 29 ++ test/audit/provenance.e2e-spec.ts | 255 +++++++++--------- test/auth/wallet-advanced.e2e-spec.ts | 202 +++++++------- test/email-linking.spec.ts | 198 ++++++++------ test/enhanced-auth.service.spec.ts | 37 ++- test/oracle-e2e.spec.ts | 200 +++++++------- test/oracle/payload-signing.service.spec.ts | 108 ++++---- test/portfolio/mpt.spec.ts | 179 ++++++------ .../portfolio-management.e2e-spec.ts | 1 - test/portfolio/portfolio.service.spec.ts | 145 +++++----- test/recovery.spec.ts | 89 +++--- test/traditional-auth.e2e-spec.ts | 174 ++++++------ test/validation.e2e-spec.ts | 20 +- test/wallet-auth.spec.ts | 69 ++--- test/wallet-management.e2e-spec.ts | 174 ++++++------ tsconfig.json | 4 +- tsconfig.test.json | 4 + 92 files changed, 2702 insertions(+), 1385 deletions(-) create mode 100644 src/search/search.controller.ts create mode 100644 src/search/search.module.ts create mode 100644 src/search/search.service.ts create mode 100644 tsconfig.test.json diff --git a/.eslintrc.js b/.eslintrc.js index 78aee0b..3f38d24 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { - project: 'tsconfig.json', + project: ['tsconfig.json', 'tsconfig.test.json'], sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin', 'import'], @@ -21,6 +21,7 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-unused-vars": "off", "import/no-relative-parent-imports": "error", }, }; \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index c22ac1a..b3c5046 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -104,6 +104,7 @@ import { KycGuard } from "./common/guard/kyc.guard"; import { StrategyAuthGuard } from "./core/auth/guards/strategy-auth.guard"; import { GlobalExceptionFilter } from "./common/filters/global-exception.filter"; import { SubmissionVerifierService } from "./blockchain/oracle/submission-verifier.service"; +import { SearchModule } from "./search/search.module"; import { LoggingMiddleware } from "./common/middleware/logging.middleware"; import { ProfilingMiddleware } from "./profiling/profiling.middleware"; @@ -211,6 +212,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; ProfilingModule, EmailModule, AgentReviewsModule, + SearchModule, LoggerModule.forRootAsync({ inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ diff --git a/src/blockchain/oracle/dto/payload-response.dto.ts b/src/blockchain/oracle/dto/payload-response.dto.ts index 0c0568d..5b3aee7 100644 --- a/src/blockchain/oracle/dto/payload-response.dto.ts +++ b/src/blockchain/oracle/dto/payload-response.dto.ts @@ -5,49 +5,95 @@ import { PayloadStatus, PayloadType } from "../entities/signed-payload.entity"; * Response DTO for payload operations */ export class PayloadResponseDto { - @ApiProperty({ description: "Unique payload UUID", example: "a1b2c3d4-1234-5678-90ef-ghijklmnopqr" }) + @ApiProperty({ + description: "Unique payload UUID", + example: "a1b2c3d4-1234-5678-90ef-ghijklmnopqr", + }) id: string; - @ApiProperty({ description: "Type of payload", enum: PayloadType, example: PayloadType.PRICE_FEED }) + @ApiProperty({ + description: "Type of payload", + enum: PayloadType, + example: PayloadType.PRICE_FEED, + }) payloadType: PayloadType; - @ApiProperty({ description: "Ethereum address that signed this payload", example: "0xAbCd1234567890abcdef1234567890abcdef1234" }) + @ApiProperty({ + description: "Ethereum address that signed this payload", + example: "0xAbCd1234567890abcdef1234567890abcdef1234", + }) signerAddress: string; @ApiProperty({ description: "Submission nonce", example: "42" }) nonce: string; - @ApiProperty({ description: "Raw payload data", type: "object", example: { token: "ETH", price: 3200.5 } }) + @ApiProperty({ + description: "Raw payload data", + type: "object", + example: { token: "ETH", price: 3200.5 }, + }) payload: Record; - @ApiProperty({ description: "Keccak256 hash of the payload", example: "0xabc123..." }) + @ApiProperty({ + description: "Keccak256 hash of the payload", + example: "0xabc123...", + }) payloadHash: string; - @ApiProperty({ description: "EIP-712 structured data hash", example: "0xdef456..." }) + @ApiProperty({ + description: "EIP-712 structured data hash", + example: "0xdef456...", + }) structuredDataHash: string; - @ApiPropertyOptional({ description: "ECDSA signature (0x-prefixed, 132 chars)", nullable: true, example: "0x..." }) + @ApiPropertyOptional({ + description: "ECDSA signature (0x-prefixed, 132 chars)", + nullable: true, + example: "0x...", + }) signature: string | null; @ApiProperty({ description: "Payload expiry timestamp" }) expiresAt: Date; - @ApiProperty({ description: "Current submission status", enum: PayloadStatus, example: PayloadStatus.PENDING }) + @ApiProperty({ + description: "Current submission status", + enum: PayloadStatus, + example: PayloadStatus.PENDING, + }) status: PayloadStatus; - @ApiPropertyOptional({ description: "On-chain transaction hash after submission", nullable: true, example: "0x..." }) + @ApiPropertyOptional({ + description: "On-chain transaction hash after submission", + nullable: true, + example: "0x...", + }) transactionHash: string | null; - @ApiPropertyOptional({ description: "Block number when confirmed on-chain", nullable: true, example: "18500000" }) + @ApiPropertyOptional({ + description: "Block number when confirmed on-chain", + nullable: true, + example: "18500000", + }) blockNumber: string | null; - @ApiProperty({ description: "Total number of submission attempts", example: 1 }) + @ApiProperty({ + description: "Total number of submission attempts", + example: 1, + }) submissionAttempts: number; - @ApiPropertyOptional({ description: "Error message if submission failed", nullable: true }) + @ApiPropertyOptional({ + description: "Error message if submission failed", + nullable: true, + }) errorMessage: string | null; - @ApiPropertyOptional({ description: "Optional metadata", nullable: true, type: "object" }) + @ApiPropertyOptional({ + description: "Optional metadata", + nullable: true, + type: "object", + }) metadata: Record | null; @ApiProperty({ description: "Record creation timestamp" }) @@ -56,9 +102,15 @@ export class PayloadResponseDto { @ApiProperty({ description: "Record last-updated timestamp" }) updatedAt: Date; - @ApiPropertyOptional({ description: "When submitted to blockchain", nullable: true }) + @ApiPropertyOptional({ + description: "When submitted to blockchain", + nullable: true, + }) submittedAt: Date | null; - @ApiPropertyOptional({ description: "When confirmed on-chain", nullable: true }) + @ApiPropertyOptional({ + description: "When confirmed on-chain", + nullable: true, + }) confirmedAt: Date | null; } diff --git a/src/blockchain/oracle/dto/sign-payload.dto.ts b/src/blockchain/oracle/dto/sign-payload.dto.ts index 12bd876..d7a55e6 100644 --- a/src/blockchain/oracle/dto/sign-payload.dto.ts +++ b/src/blockchain/oracle/dto/sign-payload.dto.ts @@ -14,8 +14,10 @@ export class SignPayloadDto { payloadId: string; @ApiProperty({ - description: "Ethereum private key (0x-prefixed, 64 hex chars). NOTE: use client-side signing in production.", - example: "0x4c0883a69102937d6231471b5dbb6e538eba2ef68e5fd63f36fe1ef7e9bb4d7f", + description: + "Ethereum private key (0x-prefixed, 64 hex chars). NOTE: use client-side signing in production.", + example: + "0x4c0883a69102937d6231471b5dbb6e538eba2ef68e5fd63f36fe1ef7e9bb4d7f", pattern: "^0x[a-fA-F0-9]{64}$", }) @IsString() diff --git a/src/blockchain/oracle/oracle.controller.ts b/src/blockchain/oracle/oracle.controller.ts index a99c523..b787141 100644 --- a/src/blockchain/oracle/oracle.controller.ts +++ b/src/blockchain/oracle/oracle.controller.ts @@ -47,8 +47,16 @@ export class OracleController { @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: "Create a new payload", description: "Create a new payload ready for signing. Requires JWT authentication." }) - @ApiResponse({ status: 201, description: "Payload created", type: PayloadResponseDto }) + @ApiOperation({ + summary: "Create a new payload", + description: + "Create a new payload ready for signing. Requires JWT authentication.", + }) + @ApiResponse({ + status: 201, + description: "Payload created", + type: PayloadResponseDto, + }) @ApiResponse({ status: 400, description: "Invalid payload data" }) @ApiResponse({ status: 401, description: "Unauthorized" }) async createPayload( @@ -72,9 +80,17 @@ export class OracleController { @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Sign a payload", description: "Sign a payload with a private key. **Use client-side signing in production.**" }) + @ApiOperation({ + summary: "Sign a payload", + description: + "Sign a payload with a private key. **Use client-side signing in production.**", + }) @ApiParam({ name: "id", description: "Payload UUID" }) - @ApiResponse({ status: 200, description: "Payload signed", type: PayloadResponseDto }) + @ApiResponse({ + status: 200, + description: "Payload signed", + type: PayloadResponseDto, + }) @ApiResponse({ status: 401, description: "Unauthorized" }) @ApiResponse({ status: 404, description: "Payload not found" }) async signPayload( @@ -94,9 +110,22 @@ export class OracleController { @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Submit payload on-chain", description: "Submit a fully signed payload to the blockchain." }) + @ApiOperation({ + summary: "Submit payload on-chain", + description: "Submit a fully signed payload to the blockchain.", + }) @ApiParam({ name: "id", description: "Payload UUID" }) - @ApiResponse({ status: 200, description: "Payload submitted", schema: { type: "object", properties: { transactionHash: { type: "string" }, payload: { $ref: "#/components/schemas/PayloadResponseDto" } } } }) + @ApiResponse({ + status: 200, + description: "Payload submitted", + schema: { + type: "object", + properties: { + transactionHash: { type: "string" }, + payload: { $ref: "#/components/schemas/PayloadResponseDto" }, + }, + }, + }) @ApiResponse({ status: 401, description: "Unauthorized" }) @ApiResponse({ status: 404, description: "Payload not found" }) async submitPayload( @@ -114,9 +143,22 @@ export class OracleController { @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Retry failed submission", description: "Retry submitting a payload that previously failed." }) + @ApiOperation({ + summary: "Retry failed submission", + description: "Retry submitting a payload that previously failed.", + }) @ApiParam({ name: "id", description: "Payload UUID" }) - @ApiResponse({ status: 200, description: "Retry initiated", schema: { type: "object", properties: { transactionHash: { type: "string" }, payload: { $ref: "#/components/schemas/PayloadResponseDto" } } } }) + @ApiResponse({ + status: 200, + description: "Retry initiated", + schema: { + type: "object", + properties: { + transactionHash: { type: "string" }, + payload: { $ref: "#/components/schemas/PayloadResponseDto" }, + }, + }, + }) @ApiResponse({ status: 401, description: "Unauthorized" }) @ApiResponse({ status: 404, description: "Payload not found" }) async retrySubmission( @@ -132,8 +174,19 @@ export class OracleController { */ @Post("verify-signature") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Verify signature off-chain", description: "Verify an ECDSA signature against a payload and expected signer address." }) - @ApiResponse({ status: 200, description: "Verification result", schema: { type: "object", properties: { valid: { type: "boolean" }, message: { type: "string" } } } }) + @ApiOperation({ + summary: "Verify signature off-chain", + description: + "Verify an ECDSA signature against a payload and expected signer address.", + }) + @ApiResponse({ + status: 200, + description: "Verification result", + schema: { + type: "object", + properties: { valid: { type: "boolean" }, message: { type: "string" } }, + }, + }) async verifySignature( @Body() verifySignatureDto: VerifySignatureDto, ): Promise<{ valid: boolean; message: string }> { @@ -150,10 +203,25 @@ export class OracleController { */ @Get("payloads/:id/verify") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "Verify payload signature", description: "Check whether the stored signature on a payload is valid for a given signer." }) + @ApiOperation({ + summary: "Verify payload signature", + description: + "Check whether the stored signature on a payload is valid for a given signer.", + }) @ApiParam({ name: "id", description: "Payload UUID" }) - @ApiQuery({ name: "expectedSigner", description: "Ethereum address of the expected signer", required: false }) - @ApiResponse({ status: 200, description: "Verification result", schema: { type: "object", properties: { valid: { type: "boolean" }, payloadId: { type: "string" } } } }) + @ApiQuery({ + name: "expectedSigner", + description: "Ethereum address of the expected signer", + required: false, + }) + @ApiResponse({ + status: 200, + description: "Verification result", + schema: { + type: "object", + properties: { valid: { type: "boolean" }, payloadId: { type: "string" } }, + }, + }) @ApiResponse({ status: 404, description: "Payload not found" }) async verifyPayloadSignature( @Param("id") id: string, @@ -177,7 +245,11 @@ export class OracleController { @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Get payload by ID" }) @ApiParam({ name: "id", description: "Payload UUID" }) - @ApiResponse({ status: 200, description: "Payload found", type: PayloadResponseDto }) + @ApiResponse({ + status: 200, + description: "Payload found", + type: PayloadResponseDto, + }) @ApiResponse({ status: 401, description: "Unauthorized" }) @ApiResponse({ status: 404, description: "Payload not found" }) async getPayload(@Param("id") id: string): Promise { @@ -190,10 +262,28 @@ export class OracleController { @Get("my-payloads") @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") - @ApiOperation({ summary: "Get my payloads", description: "Retrieve all payloads belonging to the authenticated wallet address." }) - @ApiQuery({ name: "status", enum: PayloadStatus, required: false, description: "Filter by submission status" }) - @ApiQuery({ name: "limit", required: false, description: "Max results to return (default 50)", type: Number }) - @ApiResponse({ status: 200, description: "List of payloads", type: [PayloadResponseDto] }) + @ApiOperation({ + summary: "Get my payloads", + description: + "Retrieve all payloads belonging to the authenticated wallet address.", + }) + @ApiQuery({ + name: "status", + enum: PayloadStatus, + required: false, + description: "Filter by submission status", + }) + @ApiQuery({ + name: "limit", + required: false, + description: "Max results to return (default 50)", + type: Number, + }) + @ApiResponse({ + status: 200, + description: "List of payloads", + type: [PayloadResponseDto], + }) @ApiResponse({ status: 401, description: "Unauthorized" }) async getMyPayloads( @Request() req, @@ -218,11 +308,19 @@ export class OracleController { * Get payloads for a specific address (public endpoint) */ @Get("payloads/address/:address") - @ApiOperation({ summary: "Get payloads by address", description: "Retrieve payloads submitted by a specific Ethereum address (public)." }) + @ApiOperation({ + summary: "Get payloads by address", + description: + "Retrieve payloads submitted by a specific Ethereum address (public).", + }) @ApiParam({ name: "address", description: "Ethereum wallet address" }) @ApiQuery({ name: "status", enum: PayloadStatus, required: false }) @ApiQuery({ name: "limit", required: false, type: Number }) - @ApiResponse({ status: 200, description: "List of payloads", type: [PayloadResponseDto] }) + @ApiResponse({ + status: 200, + description: "List of payloads", + type: [PayloadResponseDto], + }) async getPayloadsForAddress( @Param("address") address: string, @Query("status") status?: PayloadStatus, @@ -243,9 +341,22 @@ export class OracleController { @Get("payloads/pending/ready") @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") - @ApiOperation({ summary: "Get pending payloads", description: "Retrieve signed payloads that are ready for on-chain submission." }) - @ApiQuery({ name: "limit", required: false, type: Number, description: "Max results (default 100)" }) - @ApiResponse({ status: 200, description: "List of pending payloads", type: [PayloadResponseDto] }) + @ApiOperation({ + summary: "Get pending payloads", + description: + "Retrieve signed payloads that are ready for on-chain submission.", + }) + @ApiQuery({ + name: "limit", + required: false, + type: Number, + description: "Max results (default 100)", + }) + @ApiResponse({ + status: 200, + description: "List of pending payloads", + type: [PayloadResponseDto], + }) @ApiResponse({ status: 401, description: "Unauthorized" }) async getPendingPayloads( @Query("limit") limit?: number, @@ -259,9 +370,20 @@ export class OracleController { * Get current nonce for an address */ @Get("nonce/:address") - @ApiOperation({ summary: "Get nonce for address", description: "Retrieve the current submission nonce for an Ethereum address." }) + @ApiOperation({ + summary: "Get nonce for address", + description: + "Retrieve the current submission nonce for an Ethereum address.", + }) @ApiParam({ name: "address", description: "Ethereum wallet address" }) - @ApiResponse({ status: 200, description: "Current nonce", schema: { type: "object", properties: { address: { type: "string" }, nonce: { type: "string" } } } }) + @ApiResponse({ + status: 200, + description: "Current nonce", + schema: { + type: "object", + properties: { address: { type: "string" }, nonce: { type: "string" } }, + }, + }) async getCurrentNonce(@Param("address") address: string): Promise<{ address: string; nonce: string; @@ -280,8 +402,19 @@ export class OracleController { @Get("my-nonce") @UseGuards(JwtAuthGuard) @ApiBearerAuth("JWT-auth") - @ApiOperation({ summary: "Get my nonce", description: "Retrieve the current submission nonce for the authenticated wallet address." }) - @ApiResponse({ status: 200, description: "Current nonce", schema: { type: "object", properties: { address: { type: "string" }, nonce: { type: "string" } } } }) + @ApiOperation({ + summary: "Get my nonce", + description: + "Retrieve the current submission nonce for the authenticated wallet address.", + }) + @ApiResponse({ + status: 200, + description: "Current nonce", + schema: { + type: "object", + properties: { address: { type: "string" }, nonce: { type: "string" } }, + }, + }) @ApiResponse({ status: 401, description: "Unauthorized" }) async getMyNonce(@Request() req): Promise<{ address: string; @@ -300,7 +433,11 @@ export class OracleController { * Get Oracle service statistics */ @Get("stats") - @ApiOperation({ summary: "Get Oracle statistics", description: "Retrieve aggregate statistics about oracle submissions and payload statuses." }) + @ApiOperation({ + summary: "Get Oracle statistics", + description: + "Retrieve aggregate statistics about oracle submissions and payload statuses.", + }) @ApiResponse({ status: 200, description: "Oracle statistics" }) async getStatistics(): Promise { return this.oracleService.getStatistics(); @@ -311,7 +448,18 @@ export class OracleController { */ @Get("health") @ApiOperation({ summary: "Oracle health check" }) - @ApiResponse({ status: 200, description: "Service is healthy", schema: { type: "object", properties: { status: { type: "string" }, timestamp: { type: "string" }, service: { type: "string" } } } }) + @ApiResponse({ + status: 200, + description: "Service is healthy", + schema: { + type: "object", + properties: { + status: { type: "string" }, + timestamp: { type: "string" }, + service: { type: "string" }, + }, + }, + }) async healthCheck(): Promise<{ status: string; timestamp: string; diff --git a/src/common/database/database.config.ts b/src/common/database/database.config.ts index e2c4afd..5aa1968 100644 --- a/src/common/database/database.config.ts +++ b/src/common/database/database.config.ts @@ -36,7 +36,8 @@ export class DatabaseConfigService { private readonly environment: string; constructor(private readonly configService: ConfigService) { - this.environment = this.configService.get("NODE_ENV") ?? "development"; + this.environment = + this.configService.get("NODE_ENV") ?? "development"; } getConnectionOptions(): DatabaseConfig { @@ -70,9 +71,7 @@ export class DatabaseConfigService { } as DataSourceOptions; } - private getLoggingLevel( - logging?: boolean | string[], - ): boolean | string[] { + private getLoggingLevel(logging?: boolean | string[]): boolean | string[] { if (this.environment === "production") { return ["error", "warn", "migration", "query-slow"]; } diff --git a/src/common/database/database.module.ts b/src/common/database/database.module.ts index df3a602..8eaa3c3 100644 --- a/src/common/database/database.module.ts +++ b/src/common/database/database.module.ts @@ -16,12 +16,15 @@ export class DatabaseModule { static forRootAsync(options: { imports?: any[]; inject?: any[]; - useFactory: (...args: any[]) => Promise | TypeOrmModuleOptions; + useFactory: ( + ...args: any[] + ) => Promise | TypeOrmModuleOptions; }): DynamicModule { const dataSourceProvider: Provider = { provide: DATABASE_DATA_SOURCE, useFactory: async (configService: ConfigService) => { - const config: TypeOrmModuleOptions = await options.useFactory(configService); + const config: TypeOrmModuleOptions = + await options.useFactory(configService); const dataSource = new DataSource(config as any); try { await dataSource.initialize(); @@ -48,7 +51,10 @@ export class DatabaseModule { }; } - static forFeature(options: { entities?: any[]; imports?: any[] }): DynamicModule { + static forFeature(options: { + entities?: any[]; + imports?: any[]; + }): DynamicModule { return { module: DatabaseModule, imports: [ diff --git a/src/common/database/database.service.spec.ts b/src/common/database/database.service.spec.ts index 0e22fa2..e0f543f 100644 --- a/src/common/database/database.service.spec.ts +++ b/src/common/database/database.service.spec.ts @@ -29,7 +29,13 @@ async function buildService( providers: [ DatabaseService, { provide: ConfigService, useValue: makeConfigService() }, - { provide: DatabaseConfigService, useValue: { getConnectionOptions: jest.fn(), getDataSourceOptions: jest.fn() } }, + { + provide: DatabaseConfigService, + useValue: { + getConnectionOptions: jest.fn(), + getDataSourceOptions: jest.fn(), + }, + }, SlowQueryLogger, { provide: getDataSourceToken(), useValue: dataSource }, { provide: "DATABASE_DATA_SOURCE", useValue: dataSource }, diff --git a/src/common/database/database.service.ts b/src/common/database/database.service.ts index d85755e..ec40943 100644 --- a/src/common/database/database.service.ts +++ b/src/common/database/database.service.ts @@ -7,11 +7,7 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource, DataSourceOptions } from "typeorm"; -import { - retry, - RetryStrategy, - ConnectionError, -} from "./retry/retry.service"; +import { retry, RetryStrategy, ConnectionError } from "./retry/retry.service"; import { DatabaseConfigService } from "./database.config"; import { SlowQueryLogger } from "./observability/slow-query.logger"; @@ -62,7 +58,9 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { baseDelay = 1000, maxDelay = 30000, ): Promise { - this.logger.log(`Initializing database connection (max retries: ${maxRetries})`); + this.logger.log( + `Initializing database connection (max retries: ${maxRetries})`, + ); try { await retry( @@ -82,8 +80,13 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { ); this.logger.log("Database connection initialized successfully"); } catch (error) { - this.logger.error(`Database connection failed after ${maxRetries} retries`, error); - throw new Error(`Unable to establish database connection: ${error.message}`); + this.logger.error( + `Database connection failed after ${maxRetries} retries`, + error, + ); + throw new Error( + `Unable to establish database connection: ${error.message}`, + ); } } @@ -203,7 +206,12 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { private isTransientError(error: any): boolean { if (!error) return false; - const transientCodes = ["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "ETIMEDOUT"]; + const transientCodes = [ + "ECONNREFUSED", + "ECONNRESET", + "ENOTFOUND", + "ETIMEDOUT", + ]; const message = error.message ?? error.code ?? ""; return transientCodes.some((code) => message.includes(code)); } diff --git a/src/common/database/environments/development.config.ts b/src/common/database/environments/development.config.ts index 580d9af..c27df6c 100644 --- a/src/common/database/environments/development.config.ts +++ b/src/common/database/environments/development.config.ts @@ -8,12 +8,19 @@ export default { pool: { max: parseInt(process.env.DB_POOL_MAX ?? "20", 10), min: parseInt(process.env.DB_POOL_MIN ?? "5", 10), - idleTimeoutMillis: parseInt(process.env.DB_POOL_IDLE_TIMEOUT ?? "30000", 10), - connectionTimeoutMillis: parseInt(process.env.DB_POOL_CONNECTION_TIMEOUT ?? "10000", 10), + idleTimeoutMillis: parseInt( + process.env.DB_POOL_IDLE_TIMEOUT ?? "30000", + 10, + ), + connectionTimeoutMillis: parseInt( + process.env.DB_POOL_CONNECTION_TIMEOUT ?? "10000", + 10, + ), }, synchronize: process.env.DB_SYNCHRONIZE === "true", logging: process.env.DB_LOGGING === "true", migrations: ["src/migrations/**/*.ts"], entities: ["src/**/*.entity.ts"], - ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : undefined, + ssl: + process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : undefined, }; diff --git a/src/common/database/environments/production.config.ts b/src/common/database/environments/production.config.ts index 76b4137..e6ea738 100644 --- a/src/common/database/environments/production.config.ts +++ b/src/common/database/environments/production.config.ts @@ -8,8 +8,14 @@ export default { pool: { max: parseInt(process.env.DB_POOL_MAX ?? "50", 10), min: parseInt(process.env.DB_POOL_MIN ?? "10", 10), - idleTimeoutMillis: parseInt(process.env.DB_POOL_IDLE_TIMEOUT ?? "60000", 10), - connectionTimeoutMillis: parseInt(process.env.DB_POOL_CONNECTION_TIMEOUT ?? "10000", 10), + idleTimeoutMillis: parseInt( + process.env.DB_POOL_IDLE_TIMEOUT ?? "60000", + 10, + ), + connectionTimeoutMillis: parseInt( + process.env.DB_POOL_CONNECTION_TIMEOUT ?? "10000", + 10, + ), }, ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: true } : undefined, synchronize: false, diff --git a/src/common/database/health/connection-monitor.service.spec.ts b/src/common/database/health/connection-monitor.service.spec.ts index d74cdb8..f04d300 100644 --- a/src/common/database/health/connection-monitor.service.spec.ts +++ b/src/common/database/health/connection-monitor.service.spec.ts @@ -32,7 +32,10 @@ async function buildModule( }, }, SlowQueryLogger, - { provide: RetryService, useValue: { timeout: () => Promise.resolve(undefined) } }, + { + provide: RetryService, + useValue: { timeout: () => Promise.resolve(undefined) }, + }, ...(overrides.providers ?? []), ], }).compile(); @@ -47,25 +50,35 @@ describe("ConnectionMonitorService", () => { describe("checkConnection", () => { it("returns healthy when query succeeds", async () => { const module = await buildModule(); - const service = module.get(ConnectionMonitorService); + const service = module.get( + ConnectionMonitorService, + ); const result = await service.checkConnection(); expect(result.status).toBe("healthy"); expect(result.responseTime).toBeGreaterThanOrEqual(0); }); it("returns degraded on transient failure", async () => { - mockDataSource.query = jest.fn().mockRejectedValue(new Error("ECONNREFUSED")); + mockDataSource.query = jest + .fn() + .mockRejectedValue(new Error("ECONNREFUSED")); const module = await buildModule(); - const service = module.get(ConnectionMonitorService); + const service = module.get( + ConnectionMonitorService, + ); const result = await service.checkConnection(); expect(result.status).toBe("degraded"); expect(result.consecutiveFailures).toBe(1); }); it("returns unhealthy after repeated transient failures", async () => { - mockDataSource.query = jest.fn().mockRejectedValue(new Error("ECONNREFUSED")); + mockDataSource.query = jest + .fn() + .mockRejectedValue(new Error("ECONNREFUSED")); const module = await buildModule(); - const service = module.get(ConnectionMonitorService); + const service = module.get( + ConnectionMonitorService, + ); await service.checkConnection(); await service.checkConnection(); await service.checkConnection(); @@ -79,7 +92,9 @@ describe("ConnectionMonitorService", () => { mockDataSource.initialize = jest.fn().mockResolvedValue(undefined); mockDataSource.query = jest.fn().mockResolvedValue([{ "?column?": 1 }]); const module = await buildModule(); - const service = module.get(ConnectionMonitorService); + const service = module.get( + ConnectionMonitorService, + ); const result = await service.reconnect(); expect(result).toBe(true); expect(mockDataSource.destroy).toHaveBeenCalled(); diff --git a/src/common/database/health/connection-monitor.service.ts b/src/common/database/health/connection-monitor.service.ts index 755d6f3..ff1ebd4 100644 --- a/src/common/database/health/connection-monitor.service.ts +++ b/src/common/database/health/connection-monitor.service.ts @@ -76,9 +76,10 @@ export class ConnectionMonitorService implements OnModuleInit, OnModuleDestroy { ); } return { - status: this.consecutiveFailures >= this.maxConsecutiveFailures - ? "unhealthy" - : "degraded", + status: + this.consecutiveFailures >= this.maxConsecutiveFailures + ? "unhealthy" + : "degraded", responseTime, activeConnections: 0, idleConnections: 0, @@ -117,7 +118,9 @@ export class ConnectionMonitorService implements OnModuleInit, OnModuleDestroy { this.intervalId = setInterval(async () => { const health = await this.checkConnection(); if (health.status === "unhealthy") { - this.logger.error("Database connection is unhealthy, attempting reconnect"); + this.logger.error( + "Database connection is unhealthy, attempting reconnect", + ); this.reconnect().then((success) => { if (!success) { this.logger.error("Automatic reconnection failed"); diff --git a/src/common/database/health/health-check.service.ts b/src/common/database/health/health-check.service.ts index f0762fa..1f9bc51 100644 --- a/src/common/database/health/health-check.service.ts +++ b/src/common/database/health/health-check.service.ts @@ -37,7 +37,12 @@ export class HealthCheckService { this.dataSource.query("SELECT 1"), new Promise((_, reject) => setTimeout( - () => reject(new Error(`Health check timed out after ${options.timeoutMs ?? this.defaultTimeout}ms`)), + () => + reject( + new Error( + `Health check timed out after ${options.timeoutMs ?? this.defaultTimeout}ms`, + ), + ), options.timeoutMs ?? this.defaultTimeout, ), ), diff --git a/src/common/database/migrations/migration.service.spec.ts b/src/common/database/migrations/migration.service.spec.ts index bfd8a86..7d3b747 100644 --- a/src/common/database/migrations/migration.service.spec.ts +++ b/src/common/database/migrations/migration.service.spec.ts @@ -18,7 +18,9 @@ function makeMockDataSource(): any { }; } -async function buildModule(dataSource: any = makeMockDataSource()): Promise { +async function buildModule( + dataSource: any = makeMockDataSource(), +): Promise { return Test.createTestingModule({ providers: [ MigrationService, diff --git a/src/common/database/migrations/migration.service.ts b/src/common/database/migrations/migration.service.ts index 8b228db..eaf4434 100644 --- a/src/common/database/migrations/migration.service.ts +++ b/src/common/database/migrations/migration.service.ts @@ -130,7 +130,9 @@ export class MigrationService { } } - private async getAppliedMigrations(dataSource: DataSource): Promise { + private async getAppliedMigrations( + dataSource: DataSource, + ): Promise { const hasTable = await dataSource.query( `SELECT EXISTS ( SELECT FROM information_schema.tables @@ -149,7 +151,9 @@ export class MigrationService { return result.map((row: any) => row.name); } - private async getMigrationHistory(dataSource: DataSource): Promise { + private async getMigrationHistory( + dataSource: DataSource, + ): Promise { const hasTable = await dataSource.query( `SELECT EXISTS ( SELECT FROM information_schema.tables diff --git a/src/common/database/observability/slow-query.logger.ts b/src/common/database/observability/slow-query.logger.ts index 333e195..e1b3d94 100644 --- a/src/common/database/observability/slow-query.logger.ts +++ b/src/common/database/observability/slow-query.logger.ts @@ -62,7 +62,11 @@ export class SlowQueryLogger { this.recentQueries = []; } - getStats(): { total: number; avgExecutionTime: number; maxExecutionTime: number } { + getStats(): { + total: number; + avgExecutionTime: number; + maxExecutionTime: number; + } { if (this.recentQueries.length === 0) { return { total: 0, avgExecutionTime: 0, maxExecutionTime: 0 }; } diff --git a/src/common/database/query-builder/query-builder.service.spec.ts b/src/common/database/query-builder/query-builder.service.spec.ts index 204756d..196b148 100644 --- a/src/common/database/query-builder/query-builder.service.spec.ts +++ b/src/common/database/query-builder/query-builder.service.spec.ts @@ -78,7 +78,14 @@ describe("QueryBuilderService", () => { }).compile(); const svc = module.get>(QueryBuilderService); await svc.findAll(Object, { - join: [{ entity: Object, alias: "a", condition: "a.id = entity.id", type: "INNER" }], + join: [ + { + entity: Object, + alias: "a", + condition: "a.id = entity.id", + type: "INNER", + }, + ], }); expect(qb.innerJoin).toHaveBeenCalled(); }); @@ -120,7 +127,11 @@ describe("QueryBuilderService", () => { createQueryBuilder: jest.fn().mockReturnValue(qb), query: qb.query, }); - const result = await service.rawQuery(Object, 'SELECT * FROM entity WHERE id = :1', ["1"]); + const result = await service.rawQuery( + Object, + "SELECT * FROM entity WHERE id = :1", + ["1"], + ); expect(result).toHaveLength(1); }); }); @@ -136,7 +147,9 @@ describe("QueryBuilderService", () => { rollbackTransaction: jest.fn().mockResolvedValue(undefined), release: jest.fn().mockResolvedValue(undefined), }), - getRepository: jest.fn().mockReturnValue({ createQueryBuilder: jest.fn().mockReturnValue(qb) }), + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue(qb), + }), } as unknown as DataSource; const module = await Test.createTestingModule({ diff --git a/src/common/database/query-builder/query-builder.service.ts b/src/common/database/query-builder/query-builder.service.ts index d0f7481..ce0c93d 100644 --- a/src/common/database/query-builder/query-builder.service.ts +++ b/src/common/database/query-builder/query-builder.service.ts @@ -15,7 +15,17 @@ export interface QueryBuilderOptions { where?: Partial; filters?: Array<{ field: keyof T; - operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "IN" | "LIKE" | "IS NULL" | "IS NOT NULL"; + operator: + | "=" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "IN" + | "LIKE" + | "IS NULL" + | "IS NOT NULL"; value?: any; }>; orderBy?: { field: keyof T; direction: SortOrder }; @@ -45,10 +55,14 @@ export class QueryBuilderService { entity: any, options: QueryBuilderOptions = {}, ): Promise<{ data: T[]; total: number }> { - const query = this.dataSource.getRepository(entity).createQueryBuilder("entity"); + const query = this.dataSource + .getRepository(entity) + .createQueryBuilder("entity"); if (options.select?.length) { - options.select.forEach((field) => query.addSelect(`entity.${String(field)}`)); + options.select.forEach((field) => + query.addSelect(`entity.${String(field)}`), + ); } if (options.where) { @@ -65,7 +79,9 @@ export class QueryBuilderService { const param = field.replace(/\./g, "_"); switch (filter.operator) { case "=": - query.andWhere(`entity.${field} = :${param}`, { [param]: filter.value }); + query.andWhere(`entity.${field} = :${param}`, { + [param]: filter.value, + }); break; case "LIKE": query.andWhere(`entity.${field} LIKE :${param}`, { @@ -73,7 +89,9 @@ export class QueryBuilderService { }); break; case "IN": - query.andWhere(`entity.${field} IN (:...${param})`, { [param]: filter.value }); + query.andWhere(`entity.${field} IN (:...${param})`, { + [param]: filter.value, + }); break; case "IS NULL": query.andWhere(`entity.${field} IS NULL`); @@ -82,7 +100,9 @@ export class QueryBuilderService { query.andWhere(`entity.${field} IS NOT NULL`); break; default: - query.andWhere(`entity.${field} ${filter.operator} :${param}`, { [param]: filter.value }); + query.andWhere(`entity.${field} ${filter.operator} :${param}`, { + [param]: filter.value, + }); } }); } @@ -101,18 +121,26 @@ export class QueryBuilderService { } if (options.groupBy?.length) { - options.groupBy.forEach((field) => query.addGroupBy(`entity.${String(field)}`)); + options.groupBy.forEach((field) => + query.addGroupBy(`entity.${String(field)}`), + ); } if (options.having) { const param = String(options.having.field).replace(/\./g, "_"); - query.having(`entity.${String(options.having.field)} ${options.having.operator} :${param}`, { - [param]: options.having.value, - }); + query.having( + `entity.${String(options.having.field)} ${options.having.operator} :${param}`, + { + [param]: options.having.value, + }, + ); } if (options.orderBy) { - query.orderBy(`entity.${String(options.orderBy.field)}`, options.orderBy.direction); + query.orderBy( + `entity.${String(options.orderBy.field)}`, + options.orderBy.direction, + ); } if (options.skip) query.skip(options.skip); @@ -126,7 +154,9 @@ export class QueryBuilderService { entity: any, options: QueryBuilderOptions = {}, ): Promise { - const query = this.dataSource.getRepository(entity).createQueryBuilder("entity"); + const query = this.dataSource + .getRepository(entity) + .createQueryBuilder("entity"); if (options.where) { Object.entries(options.where).forEach(([key, value]) => { if (value !== undefined && value !== null) { @@ -147,7 +177,9 @@ export class QueryBuilderService { entity: any, options: QueryBuilderOptions = {}, ): Promise { - const query = this.dataSource.getRepository(entity).createQueryBuilder("entity"); + const query = this.dataSource + .getRepository(entity) + .createQueryBuilder("entity"); if (options.where) { Object.entries(options.where).forEach(([key, value]) => { if (value !== undefined && value !== null) { @@ -160,9 +192,13 @@ export class QueryBuilderService { const field = String(filter.field); const param = field.replace(/\./g, "_"); if (filter.operator === "IN") { - query.andWhere(`entity.${field} IN (:...${param})`, { [param]: filter.value }); + query.andWhere(`entity.${field} IN (:...${param})`, { + [param]: filter.value, + }); } else { - query.andWhere(`entity.${field} ${filter.operator} :${param}`, { [param]: filter.value }); + query.andWhere(`entity.${field} ${filter.operator} :${param}`, { + [param]: filter.value, + }); } }); } @@ -177,9 +213,7 @@ export class QueryBuilderService { return this.dataSource.getRepository(entity).query(sql, parameters); } - async transaction( - callback: (queryRunner: any) => Promise, - ): Promise { + async transaction(callback: (queryRunner: any) => Promise): Promise { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); diff --git a/src/common/database/repositories/audit-log.repository.spec.ts b/src/common/database/repositories/audit-log.repository.spec.ts index 296fc90..7dca7d7 100644 --- a/src/common/database/repositories/audit-log.repository.spec.ts +++ b/src/common/database/repositories/audit-log.repository.spec.ts @@ -41,8 +41,15 @@ describe("AuditLogRepository", () => { describe("createLog", () => { it("creates audit log with defaults", async () => { - repository.save = jest.fn().mockResolvedValue({ id: "1", action: AuditAction.ACCESS, level: LogLevel.INFO }); - const result = await repo.createLog({ action: AuditAction.ACCESS, ipAddress: "127.0.0.1" }); + repository.save = jest.fn().mockResolvedValue({ + id: "1", + action: AuditAction.ACCESS, + level: LogLevel.INFO, + }); + const result = await repo.createLog({ + action: AuditAction.ACCESS, + ipAddress: "127.0.0.1", + }); expect(repository.save).toHaveBeenCalled(); expect(result.action).toBe(AuditAction.ACCESS); }); @@ -52,7 +59,9 @@ describe("AuditLogRepository", () => { it("queries by userId", async () => { qb.getMany.mockResolvedValue([{ id: "1" }]); const result = await repo.findByUserId("user-1"); - expect(qb.andWhere).toHaveBeenCalledWith("entity.userId = :userId", { userId: "user-1" }); + expect(qb.andWhere).toHaveBeenCalledWith("entity.userId = :userId", { + userId: "user-1", + }); expect(result).toHaveLength(1); }); }); @@ -69,7 +78,10 @@ describe("AuditLogRepository", () => { describe("findByDateRange", () => { it("queries by date range", async () => { qb.getMany.mockResolvedValue([{ id: "1" }]); - const result = await repo.findByDateRange(new Date("2024-01-01"), new Date("2024-12-31")); + const result = await repo.findByDateRange( + new Date("2024-01-01"), + new Date("2024-12-31"), + ); expect(qb.andWhere).toHaveBeenCalledTimes(2); expect(result).toHaveLength(1); }); diff --git a/src/common/database/repositories/base.repository.spec.ts b/src/common/database/repositories/base.repository.spec.ts index b5fee02..145e958 100644 --- a/src/common/database/repositories/base.repository.spec.ts +++ b/src/common/database/repositories/base.repository.spec.ts @@ -91,8 +91,12 @@ describe("BaseRepository", () => { describe("create", () => { it("creates and returns entity", async () => { - repository.create = jest.fn().mockReturnValue({ id: "1", name: "new" } as any); - repository.save = jest.fn().mockResolvedValue({ id: "1", name: "new" } as any); + repository.create = jest + .fn() + .mockReturnValue({ id: "1", name: "new" } as any); + repository.save = jest + .fn() + .mockResolvedValue({ id: "1", name: "new" } as any); const result = await repo.create({ name: "new" } as any); expect(repository.save).toHaveBeenCalled(); }); diff --git a/src/common/database/repositories/base.repository.ts b/src/common/database/repositories/base.repository.ts index 614d7f7..ca28043 100644 --- a/src/common/database/repositories/base.repository.ts +++ b/src/common/database/repositories/base.repository.ts @@ -19,7 +19,9 @@ export interface FindOptions { relations?: string[]; } -export abstract class BaseRepository implements IBaseRepository { +export abstract class BaseRepository< + T extends BaseEntity, +> implements IBaseRepository { private readonly dataSource: DataSource; private readonly entityClass: new () => T; @@ -54,7 +56,9 @@ export abstract class BaseRepository implements IBaseRepos if (skip != null) query.skip(skip); if (take != null) query.take(take); if (relations?.length) { - relations.forEach((relation) => query.leftJoinAndSelect(`entity.${relation}`, relation)); + relations.forEach((relation) => + query.leftJoinAndSelect(`entity.${relation}`, relation), + ); } return query.getMany(); } diff --git a/src/common/database/retry/retry.service.spec.ts b/src/common/database/retry/retry.service.spec.ts index 96a4be0..a3525a5 100644 --- a/src/common/database/retry/retry.service.spec.ts +++ b/src/common/database/retry/retry.service.spec.ts @@ -35,36 +35,58 @@ describe("retry.service", () => { it("throws after max retries exceeded", async () => { await expect( - retry( - () => Promise.reject(new Error("ECONNREFUSED")), - { maxRetries: 2, baseDelay: 0, maxDelay: 0 }, - ), + retry(() => Promise.reject(new Error("ECONNREFUSED")), { + maxRetries: 2, + baseDelay: 0, + maxDelay: 0, + }), ).rejects.toThrow("ECONNREFUSED"); }); it("does not retry non-transient errors", async () => { await expect( - retry( - () => Promise.reject(new Error("ValidationError")), - { maxRetries: 3, baseDelay: 0, maxDelay: 0 }, - ), + retry(() => Promise.reject(new Error("ValidationError")), { + maxRetries: 3, + baseDelay: 0, + maxDelay: 0, + }), ).rejects.toThrow("ValidationError"); }); }); describe("calculateDelay", () => { it("returns base delay for fixed strategy", () => { - expect(calculateDelay(RetryStrategy.FIXED_DELAY, 1, 1000, 2, 30000)).toBe(1000); + expect(calculateDelay(RetryStrategy.FIXED_DELAY, 1, 1000, 2, 30000)).toBe( + 1000, + ); }); it("exponentially increases delay", () => { - const delay0 = calculateDelay(RetryStrategy.EXPONENTIAL_BACKOFF, 0, 1000, 2, 30000); - const delay1 = calculateDelay(RetryStrategy.EXPONENTIAL_BACKOFF, 1, 1000, 2, 30000); + const delay0 = calculateDelay( + RetryStrategy.EXPONENTIAL_BACKOFF, + 0, + 1000, + 2, + 30000, + ); + const delay1 = calculateDelay( + RetryStrategy.EXPONENTIAL_BACKOFF, + 1, + 1000, + 2, + 30000, + ); expect(delay1).toBe(delay0 * 2); }); it("caps delay at maxDelay", () => { - const delay = calculateDelay(RetryStrategy.EXPONENTIAL_BACKOFF, 10, 1000, 2, 30000); + const delay = calculateDelay( + RetryStrategy.EXPONENTIAL_BACKOFF, + 10, + 1000, + 2, + 30000, + ); expect(delay).toBe(30000); }); }); diff --git a/src/common/database/retry/retry.service.ts b/src/common/database/retry/retry.service.ts index 9eb630b..55c3ae6 100644 --- a/src/common/database/retry/retry.service.ts +++ b/src/common/database/retry/retry.service.ts @@ -9,7 +9,12 @@ export interface RetryOptions { baseDelay: number; maxDelay: number; strategy: RetryStrategy; - backoff?: { type?: string; factor?: number; delay?: number; maxDelay?: number }; + backoff?: { + type?: string; + factor?: number; + delay?: number; + maxDelay?: number; + }; shouldRetry?: (error: any) => boolean; } @@ -45,7 +50,13 @@ export function retry( if (!shouldRetry) { throw error; } - const delay = calculateDelay(resolvedStrategy, attemptIndex, resolvedBaseDelay, resolvedFactor, resolvedMaxDelay); + const delay = calculateDelay( + resolvedStrategy, + attemptIndex, + resolvedBaseDelay, + resolvedFactor, + resolvedMaxDelay, + ); await sleep(delay); return tryExecute(attemptIndex + 1); } diff --git a/src/common/database/strategies/snake-naming.strategy.spec.ts b/src/common/database/strategies/snake-naming.strategy.spec.ts index b2549b7..3ffff9d 100644 --- a/src/common/database/strategies/snake-naming.strategy.spec.ts +++ b/src/common/database/strategies/snake-naming.strategy.spec.ts @@ -11,7 +11,9 @@ describe("SnakeNamingStrategy", () => { }); it("converts pascal case to snake case", () => { - expect(strategy.tableName("UserProfile", undefined)).toBe("user_profiles"); + expect(strategy.tableName("UserProfile", undefined)).toBe( + "user_profiles", + ); }); it("handles single word names", () => { @@ -21,11 +23,15 @@ describe("SnakeNamingStrategy", () => { describe("columnName", () => { it("handles custom column names", () => { - expect(strategy.columnName("createdAt", "created_at", [])).toBe("created_at"); + expect(strategy.columnName("createdAt", "created_at", [])).toBe( + "created_at", + ); }); it("handles embedded prefixes", () => { - expect(strategy.columnName("name", undefined, ["address"])).toBe("address_name"); + expect(strategy.columnName("name", undefined, ["address"])).toBe( + "address_name", + ); }); }); @@ -37,7 +43,9 @@ describe("SnakeNamingStrategy", () => { describe("joinTableName", () => { it("generates join table name", () => { - expect(strategy.joinTableName("User", "Role", "roles", "users")).toBe("join__user_roles_users__role"); + expect(strategy.joinTableName("User", "Role", "roles", "users")).toBe( + "join__user_roles_users__role", + ); }); }); @@ -49,7 +57,9 @@ describe("SnakeNamingStrategy", () => { describe("indexName", () => { it("generates index name", () => { - expect(strategy.indexName("users", ["email", "id"])).toBe("users_email_id_idx"); + expect(strategy.indexName("users", ["email", "id"])).toBe( + "users_email_id_idx", + ); }); }); }); diff --git a/src/common/database/strategies/snake-naming.strategy.ts b/src/common/database/strategies/snake-naming.strategy.ts index 89c39aa..a144e2f 100644 --- a/src/common/database/strategies/snake-naming.strategy.ts +++ b/src/common/database/strategies/snake-naming.strategy.ts @@ -11,7 +11,11 @@ export class SnakeNamingStrategy implements NamingStrategyInterface { return `${this.pascalToSnake(originalClosureTableName, true)}_closure`; } - columnName(propertyName: string, customName: string | undefined, embeddedPrefixes: string[]): string { + columnName( + propertyName: string, + customName: string | undefined, + embeddedPrefixes: string[], + ): string { const prefix = embeddedPrefixes.length ? embeddedPrefixes.map((p) => this.pascalToSnake(p)).join("_") + "_" : ""; @@ -23,51 +27,99 @@ export class SnakeNamingStrategy implements NamingStrategyInterface { } primaryKeyName(tableOrName: any, columnNames: string[]): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_${columnNames.join("_")}_pk`; } uniqueConstraintName(tableOrName: any, columnNames: string[]): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_${columnNames.join("_")}_unique`; } - relationConstraintName(tableOrName: any, columnNames: string[], where?: string): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + relationConstraintName( + tableOrName: any, + columnNames: string[], + where?: string, + ): string { + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_${columnNames.join("_")}_rel`; } defaultConstraintName(tableOrName: any, columnName: string): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_${this.pascalToSnake(columnName)}_default`; } - foreignKeyName(tableOrName: any, columnNames: string[], referencedTablePath?: string, referencedColumnNames?: string[]): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; - const ref = referencedTablePath ? this.pascalToSnake(referencedTablePath) : "unknown"; + foreignKeyName( + tableOrName: any, + columnNames: string[], + referencedTablePath?: string, + referencedColumnNames?: string[], + ): string { + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); + const ref = referencedTablePath + ? this.pascalToSnake(referencedTablePath) + : "unknown"; return `${this.pascalToSnake(table)}_${columnNames.join("_")}_${ref}_fk`; } indexName(tableOrName: any, columns: string[], where?: string): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_${columns.join("_")}_idx`; } - checkConstraintName(tableOrName: any, expression: string, isEnum?: boolean): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + checkConstraintName( + tableOrName: any, + expression: string, + isEnum?: boolean, + ): string { + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_check`; } exclusionConstraintName(tableOrName: any, expression: string): string { - const table = typeof tableOrName === "string" ? tableOrName : tableOrName?.name ?? "table"; + const table = + typeof tableOrName === "string" + ? tableOrName + : (tableOrName?.name ?? "table"); return `${this.pascalToSnake(table)}_exclude`; } joinColumnName(relationName: string, referencedColumnName: string): string { - return this.pascalToSnake(relationName) + "_" + this.pascalToSnake(referencedColumnName); - } - - joinTableName(firstTableName: string, secondTableName: string, firstPropertyName: string, secondPropertyName: string): string { + return ( + this.pascalToSnake(relationName) + + "_" + + this.pascalToSnake(referencedColumnName) + ); + } + + joinTableName( + firstTableName: string, + secondTableName: string, + firstPropertyName: string, + secondPropertyName: string, + ): string { return [ this.pascalToSnake("join_" + firstTableName + "_" + firstPropertyName), this.pascalToSnake(secondPropertyName + "_" + secondTableName), @@ -78,11 +130,19 @@ export class SnakeNamingStrategy implements NamingStrategyInterface { return `${this.pascalToSnake(columnName)}${index}`; } - joinTableColumnName(tableName: string, propertyName: string, columnName?: string): string { + joinTableColumnName( + tableName: string, + propertyName: string, + columnName?: string, + ): string { return `${this.pascalToSnake(columnName || propertyName)}`; } - joinTableInverseColumnName(tableName: string, propertyName: string, columnName?: string): string { + joinTableInverseColumnName( + tableName: string, + propertyName: string, + columnName?: string, + ): string { return `${this.pascalToSnake(columnName || propertyName)}`; } diff --git a/src/config/logger.ts b/src/config/logger.ts index ec8e679..638d4ae 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,5 +1,5 @@ // import pino from "pino"; -const pino = require("pino"); +import pino from "pino"; const isDevelopment = process.env.NODE_ENV === "development"; diff --git a/src/core/auth/challenge.service.spec.ts b/src/core/auth/challenge.service.spec.ts index 92b5a3b..817c681 100644 --- a/src/core/auth/challenge.service.spec.ts +++ b/src/core/auth/challenge.service.spec.ts @@ -51,4 +51,4 @@ describe("ChallengeService", () => { expect(result).toBeNull(); }); }); -}); \ No newline at end of file +}); diff --git a/src/core/auth/token-blacklist.service.spec.ts b/src/core/auth/token-blacklist.service.spec.ts index e7eafad..dd9ce5a 100644 --- a/src/core/auth/token-blacklist.service.spec.ts +++ b/src/core/auth/token-blacklist.service.spec.ts @@ -12,7 +12,9 @@ describe("TokenBlacklistService", () => { jest.clearAllMocks(); }); - afterEach(() => { jest.restoreAllMocks(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); it("should be defined", () => { expect(service).toBeDefined(); @@ -41,4 +43,4 @@ describe("TokenBlacklistService", () => { expect(service.isRevoked("jti-expired")).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/src/core/auth/wallet-auth.service.spec.ts b/src/core/auth/wallet-auth.service.spec.ts index 81a4fbf..40a5f0b 100644 --- a/src/core/auth/wallet-auth.service.spec.ts +++ b/src/core/auth/wallet-auth.service.spec.ts @@ -17,6 +17,8 @@ import { NotFoundException, } from "@nestjs/common"; +import { verifyMessage as mockVerifyMessage } from "ethers"; + // Mock ethers jest.mock("ethers", () => ({ verifyMessage: jest.fn(), @@ -156,7 +158,7 @@ describe("WalletAuthService", () => { mockWalletRepository.find.mockResolvedValue([mockWallet]); // Get the mocked verifyMessage - verifyMessage = require("ethers").verifyMessage; + verifyMessage = mockVerifyMessage as jest.Mock; const module: TestingModule = await Test.createTestingModule({ providers: [ diff --git a/src/core/user/admin-role.controller.spec.ts b/src/core/user/admin-role.controller.spec.ts index f821a8a..db4709c 100644 --- a/src/core/user/admin-role.controller.spec.ts +++ b/src/core/user/admin-role.controller.spec.ts @@ -10,7 +10,9 @@ import { AdminTwoFactorGuard } from "src/core/auth/guards/admin-two-factor.guard describe("AdminRoleController", () => { let controller: AdminRoleController; - let userService: jest.Mocked>; + let userService: jest.Mocked< + Pick + >; const makeUser = (role: Role): User => ({ id: "user-1", role }) as unknown as User; diff --git a/src/core/user/admin-role.controller.ts b/src/core/user/admin-role.controller.ts index bbee0dc..618ca6d 100644 --- a/src/core/user/admin-role.controller.ts +++ b/src/core/user/admin-role.controller.ts @@ -54,7 +54,9 @@ export class AdminRoleController { @ApiOperation({ summary: "Get a user's current role" }) @ApiResponse({ status: 200, description: "The user's current role" }) @ApiResponse({ status: 404, description: "User not found" }) - async getUserRole(@Param("id") id: string): Promise<{ id: string; role: Role }> { + async getUserRole( + @Param("id") id: string, + ): Promise<{ id: string; role: Role }> { const user = await this.userService.findOneOrFail(id); return { id: user.id, role: user.role }; } diff --git a/src/core/user/role-seeder.service.ts b/src/core/user/role-seeder.service.ts index 80e0a10..5f9d6de 100644 --- a/src/core/user/role-seeder.service.ts +++ b/src/core/user/role-seeder.service.ts @@ -62,9 +62,7 @@ export class RoleSeederService implements OnModuleInit { } const user = await this.userRepository.findOne({ - where: email - ? { email } - : { walletAddress: wallet!.toLowerCase() }, + where: email ? { email } : { walletAddress: wallet!.toLowerCase() }, }); const target = email ?? wallet; diff --git a/src/dashboard/websocket/services/reconnection.service.ts b/src/dashboard/websocket/services/reconnection.service.ts index 6a6008f..e58c41a 100644 --- a/src/dashboard/websocket/services/reconnection.service.ts +++ b/src/dashboard/websocket/services/reconnection.service.ts @@ -210,7 +210,8 @@ export class WebSocketClientManager { private socket: any = null; private reconnectService: ReconnectionService; private eventBuffer: any[] = []; - private messageHandlers: Map = new Map(); + private messageHandlers: Map void)[]> = + new Map(); private connectionState: | "disconnected" | "connecting" @@ -455,7 +456,7 @@ export class WebSocketClientManager { /** * Register event handler */ - on(event: string, handler: Function): void { + on(event: string, handler: (...args: any[]) => void): void { if (!this.messageHandlers.has(event)) { this.messageHandlers.set(event, []); } @@ -465,7 +466,7 @@ export class WebSocketClientManager { /** * Remove event handler */ - off(event: string, handler: Function): void { + off(event: string, handler: (...args: any[]) => void): void { const handlers = this.messageHandlers.get(event); if (handlers) { const index = handlers.indexOf(handler); diff --git a/src/dashboard/websocket/websocket.stress.spec.ts b/src/dashboard/websocket/websocket.stress.spec.ts index 6f554b4..423619a 100644 --- a/src/dashboard/websocket/websocket.stress.spec.ts +++ b/src/dashboard/websocket/websocket.stress.spec.ts @@ -45,7 +45,7 @@ class MockSocket { public connected: boolean = false; public id: string; public auth: any = {}; - public listeners: Map = new Map(); + public listeners: Map void)[]> = new Map(); private reconnectAttempts: number = 0; private maxReconnectAttempts: number = 10; @@ -86,14 +86,14 @@ class MockSocket { this.emit("disconnect", "io client disconnect"); } - on(event: string, callback: Function) { + on(event: string, callback: (...args: any[]) => void) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } - off(event: string, callback: Function) { + off(event: string, callback: (...args: any[]) => void) { const handlers = this.listeners.get(event); if (handlers) { const index = handlers.indexOf(callback); @@ -180,10 +180,7 @@ describe("WebSocket Stress Tests", () => { // Clear all connections and buffers before each test eventBuffer.clearAllBuffers(); // Clear all connections from the connection manager to prevent state leakage - const allClients = Array.from( - { length: 2000 }, - (_, i) => `client-${i}`, - ); + const allClients = Array.from({ length: 2000 }, (_, i) => `client-${i}`); for (const clientId of allClients) { connectionManager.removeConnection(clientId); } diff --git a/src/email/dto/send-email.dto.ts b/src/email/dto/send-email.dto.ts index 49ffafb..d53efdb 100644 --- a/src/email/dto/send-email.dto.ts +++ b/src/email/dto/send-email.dto.ts @@ -1,8 +1,22 @@ -import { IsEmail, IsString, IsOptional, IsEnum, IsObject, IsArray, ValidateNested, MaxLength, MinLength } from "class-validator"; +import { + IsEmail, + IsString, + IsOptional, + IsEnum, + IsObject, + IsArray, + ValidateNested, + MaxLength, + MinLength, +} from "class-validator"; import { Type } from "class-transformer"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -export enum EmailPriority { LOW = "low", NORMAL = "normal", HIGH = "high" } +export enum EmailPriority { + LOW = "low", + NORMAL = "normal", + HIGH = "high", +} export class EmailAttachmentDto { @ApiProperty() @IsString() filename: string; @@ -13,18 +27,40 @@ export class EmailAttachmentDto { } export class SendEmailDto { - @ApiProperty({ example: "user@example.com" }) @IsEmail({}, { each: true }) @IsArray() to: string[]; - @ApiProperty({ example: "Welcome!" }) @IsString() @MinLength(1) @MaxLength(998) subject: string; + @ApiProperty({ example: "user@example.com" }) + @IsEmail({}, { each: true }) + @IsArray() + to: string[]; + @ApiProperty({ example: "Welcome!" }) + @IsString() + @MinLength(1) + @MaxLength(998) + subject: string; @ApiPropertyOptional() @IsOptional() @IsString() templateName?: string; - @ApiPropertyOptional({ example: { name: "Alice" } }) @IsOptional() @IsObject() templateVars?: Record; + @ApiPropertyOptional({ example: { name: "Alice" } }) + @IsOptional() + @IsObject() + templateVars?: Record; @ApiPropertyOptional() @IsOptional() @IsString() html?: string; @ApiPropertyOptional() @IsOptional() @IsString() text?: string; - @ApiPropertyOptional({ type: [EmailAttachmentDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => EmailAttachmentDto) attachments?: EmailAttachmentDto[]; - @ApiPropertyOptional({ enum: EmailPriority, default: EmailPriority.NORMAL }) @IsOptional() @IsEnum(EmailPriority) priority?: EmailPriority; + @ApiPropertyOptional({ type: [EmailAttachmentDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAttachmentDto) + attachments?: EmailAttachmentDto[]; + @ApiPropertyOptional({ enum: EmailPriority, default: EmailPriority.NORMAL }) + @IsOptional() + @IsEnum(EmailPriority) + priority?: EmailPriority; } export class CreateTemplateDto { - @ApiProperty({ example: "welcome" }) @IsString() @MinLength(2) @MaxLength(100) name: string; + @ApiProperty({ example: "welcome" }) + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; @ApiProperty() @IsString() htmlContent: string; @ApiPropertyOptional() @IsOptional() @IsString() textContent?: string; @ApiPropertyOptional() @IsOptional() @IsString() subject?: string; @@ -32,5 +68,9 @@ export class CreateTemplateDto { } export class SendBulkEmailDto { - @ApiProperty({ type: [SendEmailDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => SendEmailDto) emails: SendEmailDto[]; -} \ No newline at end of file + @ApiProperty({ type: [SendEmailDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SendEmailDto) + emails: SendEmailDto[]; +} diff --git a/src/email/email.controller.ts b/src/email/email.controller.ts index 4cb29f8..171595b 100644 --- a/src/email/email.controller.ts +++ b/src/email/email.controller.ts @@ -1,31 +1,89 @@ -import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus, Logger } from "@nestjs/common"; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger"; +import { + Controller, + Get, + Post, + Param, + Body, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiParam, +} from "@nestjs/swagger"; import { EmailService } from "./email.service"; -import { SendEmailDto, SendBulkEmailDto, CreateTemplateDto } from "./dto/send-email.dto"; +import { + SendEmailDto, + SendBulkEmailDto, + CreateTemplateDto, +} from "./dto/send-email.dto"; -@ApiTags("Email") @ApiBearerAuth() @Controller("email") +@ApiTags("Email") +@ApiBearerAuth() +@Controller("email") export class EmailController { private readonly logger = new Logger(EmailController.name); constructor(private readonly emailService: EmailService) {} - @Post("send") @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Send a single email" }) @ApiResponse({ status: 201 }) - async sendEmail(@Body() dto: SendEmailDto) { return { success: true, emailLog: await this.emailService.sendEmail(dto) }; } + @Post("send") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Send a single email" }) + @ApiResponse({ status: 201 }) + async sendEmail(@Body() dto: SendEmailDto) { + return { success: true, emailLog: await this.emailService.sendEmail(dto) }; + } - @Post("send-bulk") @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Send bulk emails" }) @ApiResponse({ status: 201 }) - async sendBulkEmail(@Body() dto: SendBulkEmailDto) { const r = await this.emailService.sendBulk(dto); return { success: true, count: r.length, emailLogs: r }; } + @Post("send-bulk") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Send bulk emails" }) + @ApiResponse({ status: 201 }) + async sendBulkEmail(@Body() dto: SendBulkEmailDto) { + const r = await this.emailService.sendBulk(dto); + return { success: true, count: r.length, emailLogs: r }; + } - @Get("status/:id") @ApiOperation({ summary: "Get email delivery status" }) @ApiParam({ name: "id" }) - async getStatus(@Param("id") id: string) { return { success: true, emailLog: await this.emailService.getDeliveryStatus(id) }; } + @Get("status/:id") + @ApiOperation({ summary: "Get email delivery status" }) + @ApiParam({ name: "id" }) + async getStatus(@Param("id") id: string) { + return { + success: true, + emailLog: await this.emailService.getDeliveryStatus(id), + }; + } - @Post("retry/:id") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Retry a failed email" }) @ApiParam({ name: "id" }) - async retryFailed(@Param("id") id: string) { return { success: true, emailLog: await this.emailService.retryFailed(id) }; } + @Post("retry/:id") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Retry a failed email" }) + @ApiParam({ name: "id" }) + async retryFailed(@Param("id") id: string) { + return { success: true, emailLog: await this.emailService.retryFailed(id) }; + } - @Post("unsubscribe") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Unsubscribe from notification emails" }) - async unsubscribe(@Body("email") email: string) { return this.emailService.unsubscribe(email); } + @Post("unsubscribe") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Unsubscribe from notification emails" }) + async unsubscribe(@Body("email") email: string) { + return this.emailService.unsubscribe(email); + } - @Get("templates") @ApiOperation({ summary: "Get all email templates" }) - async getTemplates() { return { success: true, templates: await this.emailService.getTemplates() }; } + @Get("templates") + @ApiOperation({ summary: "Get all email templates" }) + async getTemplates() { + return { success: true, templates: await this.emailService.getTemplates() }; + } - @Post("templates") @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Create a new email template" }) - async createTemplate(@Body() dto: CreateTemplateDto) { return { success: true, template: await this.emailService.createTemplate(dto) }; } -} \ No newline at end of file + @Post("templates") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a new email template" }) + async createTemplate(@Body() dto: CreateTemplateDto) { + return { + success: true, + template: await this.emailService.createTemplate(dto), + }; + } +} diff --git a/src/email/email.module.ts b/src/email/email.module.ts index e4c3bc8..a287414 100644 --- a/src/email/email.module.ts +++ b/src/email/email.module.ts @@ -21,13 +21,25 @@ import { SesEmailProvider } from "./providers/ses-email.provider"; imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - redis: { host: configService.get("REDIS_HOST", "localhost"), port: configService.get("REDIS_PORT", 6379), password: configService.get("REDIS_PASSWORD") }, + redis: { + host: configService.get("REDIS_HOST", "localhost"), + port: configService.get("REDIS_PORT", 6379), + password: configService.get("REDIS_PASSWORD"), + }, defaultJobOptions: { removeOnComplete: 100, removeOnFail: 500 }, }), }), ], controllers: [EmailController], - providers: [EmailService, EmailQueueService, EmailProcessor, TemplateEngineService, SmtpEmailProvider, SendgridEmailProvider, SesEmailProvider], + providers: [ + EmailService, + EmailQueueService, + EmailProcessor, + TemplateEngineService, + SmtpEmailProvider, + SendgridEmailProvider, + SesEmailProvider, + ], exports: [EmailService, TemplateEngineService], }) -export class EmailModule {} \ No newline at end of file +export class EmailModule {} diff --git a/src/email/email.service.spec.ts b/src/email/email.service.spec.ts index 0686db6..8ac3c94 100644 --- a/src/email/email.service.spec.ts +++ b/src/email/email.service.spec.ts @@ -5,25 +5,57 @@ import { Repository } from "typeorm"; import { EmailService } from "./email.service"; import { EmailQueueService } from "./services/email-queue.service"; import { TemplateEngineService } from "./services/template-engine.service"; -import { EmailLog, EmailStatus, EmailProvider } from "./entities/email-log.entity"; +import { + EmailLog, + EmailStatus, + EmailProvider, +} from "./entities/email-log.entity"; import { SendEmailDto } from "./dto/send-email.dto"; -const mockEmailLogRepository = () => ({ create: jest.fn(), save: jest.fn(), findOne: jest.fn(), update: jest.fn() }); -const mockEmailQueueService = () => ({ enqueueEmail: jest.fn(), processEmail: jest.fn(), getPendingEmailCount: jest.fn(), getFailedEmailLogs: jest.fn() }); -const mockConfigService = () => ({ get: jest.fn((key: string, fb?: any) => ({ EMAIL_PROVIDER: "smtp", EMAIL_FROM: "test@alian-structure.com" }[key] || fb)) }); +const mockEmailLogRepository = () => ({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), +}); +const mockEmailQueueService = () => ({ + enqueueEmail: jest.fn(), + processEmail: jest.fn(), + getPendingEmailCount: jest.fn(), + getFailedEmailLogs: jest.fn(), +}); +const mockConfigService = () => ({ + get: jest.fn( + (key: string, fb?: any) => + ({ EMAIL_PROVIDER: "smtp", EMAIL_FROM: "test@alian-structure.com" })[ + key + ] || fb, + ), +}); describe("EmailService", () => { let service: EmailService; let emailLogRepo: jest.Mocked>; - let queueService: EmailQueueService & { enqueueEmail: jest.Mock; processEmail: jest.Mock; getPendingEmailCount: jest.Mock; getFailedEmailLogs: jest.Mock }; + let queueService: EmailQueueService & { + enqueueEmail: jest.Mock; + processEmail: jest.Mock; + getPendingEmailCount: jest.Mock; + getFailedEmailLogs: jest.Mock; + }; let templateEngine: TemplateEngineService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [EmailService, TemplateEngineService, - { provide: getRepositoryToken(EmailLog), useFactory: mockEmailLogRepository }, + providers: [ + EmailService, + TemplateEngineService, + { + provide: getRepositoryToken(EmailLog), + useFactory: mockEmailLogRepository, + }, { provide: EmailQueueService, useFactory: mockEmailQueueService }, - { provide: ConfigService, useFactory: mockConfigService }], + { provide: ConfigService, useFactory: mockConfigService }, + ], }).compile(); service = module.get(EmailService); emailLogRepo = module.get(getRepositoryToken(EmailLog)); @@ -31,11 +63,18 @@ describe("EmailService", () => { templateEngine = module.get(TemplateEngineService); }); - it("should be defined", () => { expect(service).toBeDefined(); }); + it("should be defined", () => { + expect(service).toBeDefined(); + }); describe("sendEmail", () => { it("should queue a single email", async () => { - const dto: SendEmailDto = { to: ["user@example.com"], subject: "Test", html: "

Hello

", text: "Hello" }; + const dto: SendEmailDto = { + to: ["user@example.com"], + subject: "Test", + html: "

Hello

", + text: "Hello", + }; const saved = { id: "uuid-1", status: EmailStatus.QUEUED }; emailLogRepo.create.mockReturnValue(saved as any); emailLogRepo.save.mockResolvedValue(saved as any); @@ -47,7 +86,11 @@ describe("EmailService", () => { }); it("should send to multiple recipients", async () => { - const dto: SendEmailDto = { to: ["a@x.com", "b@x.com"], subject: "Multi", html: "

Bulk

" }; + const dto: SendEmailDto = { + to: ["a@x.com", "b@x.com"], + subject: "Multi", + html: "

Bulk

", + }; emailLogRepo.create.mockReturnValue({ id: "x" } as any); emailLogRepo.save.mockResolvedValue({ id: "x" } as any); queueService.enqueueEmail.mockResolvedValue({ id: 1 } as any); @@ -56,7 +99,12 @@ describe("EmailService", () => { }); it("should render template when templateName provided", async () => { - const dto: SendEmailDto = { to: ["user@example.com"], subject: "Welcome", templateName: "welcome", templateVars: { name: "Alice" } }; + const dto: SendEmailDto = { + to: ["user@example.com"], + subject: "Welcome", + templateName: "welcome", + templateVars: { name: "Alice" }, + }; emailLogRepo.create.mockReturnValue({ id: "t1" } as any); emailLogRepo.save.mockResolvedValue({ id: "t1" } as any); queueService.enqueueEmail.mockResolvedValue({ id: 1 } as any); @@ -72,53 +120,93 @@ describe("EmailService", () => { emailLogRepo.create.mockReturnValue({ id: "b1" } as any); emailLogRepo.save.mockResolvedValue({ id: "b1" } as any); queueService.enqueueEmail.mockResolvedValue({ id: 1 } as any); - const r = await service.sendBulk({ emails: [{ to: ["a@x.com"], subject: "A", html: "

A

" }, { to: ["b@x.com"], subject: "B", html: "

B

" }] } as any); + const r = await service.sendBulk({ + emails: [ + { to: ["a@x.com"], subject: "A", html: "

A

" }, + { to: ["b@x.com"], subject: "B", html: "

B

" }, + ], + } as any); expect(r).toHaveLength(2); }); }); describe("getDeliveryStatus", () => { it("should return email log by id", async () => { - emailLogRepo.findOne.mockResolvedValue({ id: "u1", status: EmailStatus.SENT } as any); + emailLogRepo.findOne.mockResolvedValue({ + id: "u1", + status: EmailStatus.SENT, + } as any); expect((await service.getDeliveryStatus("u1")).id).toBe("u1"); }); it("should throw when not found", async () => { emailLogRepo.findOne.mockResolvedValue(null); - await expect(service.getDeliveryStatus("missing")).rejects.toThrow("not found"); + await expect(service.getDeliveryStatus("missing")).rejects.toThrow( + "not found", + ); }); }); describe("retryFailed", () => { it("should requeue a failed email", async () => { - emailLogRepo.findOne.mockResolvedValue({ id: "f1", status: EmailStatus.FAILED } as any); + emailLogRepo.findOne.mockResolvedValue({ + id: "f1", + status: EmailStatus.FAILED, + } as any); queueService.enqueueEmail.mockResolvedValue({ id: 1 } as any); await service.retryFailed("f1"); - expect(emailLogRepo.update).toHaveBeenCalledWith("f1", { status: EmailStatus.QUEUED, attempts: 0, errorMessage: null }); + expect(emailLogRepo.update).toHaveBeenCalledWith("f1", { + status: EmailStatus.QUEUED, + attempts: 0, + errorMessage: null, + }); }); it("should throw if not failed", async () => { - emailLogRepo.findOne.mockResolvedValue({ id: "s1", status: EmailStatus.SENT } as any); - await expect(service.retryFailed("s1")).rejects.toThrow("not in failed status"); + emailLogRepo.findOne.mockResolvedValue({ + id: "s1", + status: EmailStatus.SENT, + } as any); + await expect(service.retryFailed("s1")).rejects.toThrow( + "not in failed status", + ); }); }); describe("unsubscribe", () => { it("should mark recipient as unsubscribed", async () => { emailLogRepo.update.mockResolvedValue({ affected: 3 } as any); - expect((await service.unsubscribe("user@example.com")).unsubscribed).toBe(true); + expect((await service.unsubscribe("user@example.com")).unsubscribed).toBe( + true, + ); }); }); describe("template rendering", () => { - it("should render template with variables", () => { expect(templateEngine.renderHtml("welcome", { name: "Bob" })).toContain("Bob"); }); - it("should HTML-escape variable values", () => { expect(templateEngine.renderHtml("welcome", { name: "