diff --git a/src/app.controller.ts b/src/app.controller.ts index 7cfe202c..d471b471 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -19,7 +19,9 @@ export class AppController { @Get() @ApiVersion([ApiVersionEnum.V1, ApiVersionEnum.V2]) - getHello(): string { return 'Welcome to PropChain API'; } + getHello(): string { + return 'Welcome to PropChain API'; + } @Get('health') @ApiVersion([ApiVersionEnum.V1, ApiVersionEnum.V2]) @@ -67,7 +69,11 @@ export class AppController { } const allOk = Object.values(checks).every((c: any) => c.status === 'ok'); - return { status: allOk ? 'OK' : 'DEGRADED', timestamp: new Date().toISOString(), services: checks }; + return { + status: allOk ? 'OK' : 'DEGRADED', + timestamp: new Date().toISOString(), + services: checks, + }; } @Get('health') diff --git a/src/auth/auth.service.captcha.spec.ts b/src/auth/auth.service.captcha.spec.ts index 6d685ec2..7943fa08 100644 --- a/src/auth/auth.service.captcha.spec.ts +++ b/src/auth/auth.service.captcha.spec.ts @@ -184,6 +184,7 @@ describe('AuthService – CAPTCHA failure lockout', () => { password: '$2b$10$invalidhash', isBlocked: false, isDeactivated: false, + isVerified: true, twoFactorEnabled: false, }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index df407871..1189798b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -125,10 +125,14 @@ export class AuthService { const passwordHash = await hashPassword(data.password, this.bcryptRounds); const verificationToken = randomToken(32); - const verificationExpiresAt = new Date(Date.now() + parseDuration( - this.configService.get('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h', - 24 * 60 * 60, - ) * 1000); + const verificationExpiresAt = new Date( + Date.now() + + parseDuration( + this.configService.get('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h', + 24 * 60 * 60, + ) * + 1000, + ); const user = await this.prisma.user.create({ data: { @@ -169,13 +173,17 @@ export class AuthService { /** * Performs mandatory security checks before validating credentials. - * + * * Ordering Contract: * 1. Lockout check: Prevent any further action if account is temporarily locked. * 2. CAPTCHA check: If failed attempts exceed threshold, require CAPTCHA to proceed. * 3. Credentials check: (Performed in the main login method after preflight) */ - private async preflightChecks(data: LoginDto): Promise { + private async preflightChecks( + data: LoginDto, + ipAddress?: string, + userAgent?: string, + ): Promise { // Check if account is locked out const isLocked = await this.rateLimitService.isAccountLocked(data.email); if (isLocked) { @@ -205,7 +213,7 @@ export class AuthService { } async login(data: LoginDto, ipAddress?: string, userAgent?: string) { - await this.preflightChecks(data); + await this.preflightChecks(data, ipAddress, userAgent); const user = await this.usersService.findByEmail(data.email); if (!user) { diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 6057d6e5..fc7fcd28 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -1,6 +1,6 @@ // @ts-nocheck -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../database/prisma.service'; import { @@ -13,9 +13,12 @@ import { @Injectable() export class DashboardService { + private readonly logger = new Logger(DashboardService.name); + constructor(private prisma: PrismaService) {} async getDashboard(userId: string): Promise { + this.logger.log(`Fetching dashboard for user ${userId}`); const [profile, stats, recentActivity, recommendations] = await Promise.all([ this.getProfileSummary(userId), this.getQuickStats(userId), diff --git a/src/main.ts b/src/main.ts index 02895fd2..7bcb8552 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,9 +16,18 @@ import { validateEnvironment } from './utils/validate-env'; async function bootstrap() { validateEnvironment(); - const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + // Node.js version check (#775) + const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); + if (nodeMajor < 18) { + logger.error(`Node.js >= 18 required, found ${process.versions.node}`); + process.exit(1); + } + + const app = await NestFactory.create(AppModule); + // Enable validation app.useGlobalPipes( new ValidationPipe({ @@ -68,6 +77,8 @@ async function bootstrap() { // Setup Swagger documentation setupSwagger(app); + app.enableShutdownHooks(); + const port = process.env.PORT || 3000; await app.listen(port); logger.log(`PropChain API running on http://localhost:${port}`); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 4bf95eb0..47ed8f6d 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -41,9 +41,21 @@ export class NotificationsService { const message = `Your transaction for property "${transaction.property.title}" has been updated to ${transaction.status}.`; const [canInApp, canEmail, canSms] = await Promise.all([ - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'inApp'), - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'email'), - this.userPreferencesService.shouldDeliverNotification(user.id, 'TRANSACTION_UPDATE', 'sms'), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'inApp', + ), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'email', + ), + this.userPreferencesService.shouldDeliverNotification( + user.id, + 'TRANSACTION_UPDATE', + 'sms', + ), ]); await Promise.all([ @@ -73,9 +85,7 @@ export class NotificationsService { transaction.status === 'CANCELLED' ? new Date().toLocaleDateString() : undefined, }) : Promise.resolve(), - canSms && user.phone - ? this.smsService.sendSms(user.phone, message) - : Promise.resolve(), + canSms && user.phone ? this.smsService.sendSms(user.phone, message) : Promise.resolve(), ]); }), ); diff --git a/src/transactions/dto/transaction.dto.ts b/src/transactions/dto/transaction.dto.ts index 3a9f8f4b..c167229a 100644 --- a/src/transactions/dto/transaction.dto.ts +++ b/src/transactions/dto/transaction.dto.ts @@ -1,6 +1,16 @@ // @ts-nocheck -import { IsString, IsNumber, IsOptional, IsEnum, IsUUID, IsDate, IsIn, Min, Max } from 'class-validator'; +import { + IsString, + IsNumber, + IsOptional, + IsEnum, + IsUUID, + IsDate, + IsIn, + Min, + Max, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -189,13 +199,19 @@ export enum TransactionAnalyticsGranularity { } export class TransactionAnalyticsQueryDto { - @ApiPropertyOptional({ description: 'Only include transactions created on or after this date. Maximum date window is 365 days when both startDate and endDate are provided.' }) + @ApiPropertyOptional({ + description: + 'Only include transactions created on or after this date. Maximum date window is 365 days when both startDate and endDate are provided.', + }) @IsOptional() @Type(() => Date) @IsDate() startDate?: Date; - @ApiPropertyOptional({ description: 'Only include transactions created on or before this date. Maximum date window is 365 days when both startDate and endDate are provided.' }) + @ApiPropertyOptional({ + description: + 'Only include transactions created on or before this date. Maximum date window is 365 days when both startDate and endDate are provided.', + }) @IsOptional() @Type(() => Date) @IsDate() diff --git a/src/transactions/transactions.service.spec.ts b/src/transactions/transactions.service.spec.ts index f6ea1362..b0604766 100644 --- a/src/transactions/transactions.service.spec.ts +++ b/src/transactions/transactions.service.spec.ts @@ -261,7 +261,11 @@ describe('TransactionsService', () => { const endDate = new Date('2026-01-02T00:00:00.000Z'); await expect( - service.getAnalytics({ startDate, endDate, granularity: TransactionAnalyticsGranularity.MONTH }), + service.getAnalytics({ + startDate, + endDate, + granularity: TransactionAnalyticsGranularity.MONTH, + }), ).rejects.toThrow(BadRequestException); expect(prisma.transaction.findMany).not.toHaveBeenCalled(); }); diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index a9477b53..a3b874ca 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -338,15 +338,7 @@ export class TransactionsService { if (query.startDate) where.createdAt.gte = query.startDate; if (query.endDate) where.createdAt.lte = query.endDate; - if (query.startDate && query.endDate) { - const diffMs = new Date(query.endDate).getTime() - new Date(query.startDate).getTime(); - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays > maxDays) { - const cappedEnd = new Date(query.startDate); - cappedEnd.setDate(cappedEnd.getDate() + maxDays); - where.createdAt.lte = cappedEnd; - } - } else if (query.startDate && !query.endDate) { + if (query.startDate && !query.endDate) { const cappedEnd = new Date(query.startDate); cappedEnd.setDate(cappedEnd.getDate() + maxDays); where.createdAt.lte = cappedEnd; @@ -540,7 +532,9 @@ export class TransactionsService { }, }) .then((result: any) => { - this.logger.log(`Tax strategy created for transaction ${transactionId}: ${dto.strategyType}`); + this.logger.log( + `Tax strategy created for transaction ${transactionId}: ${dto.strategyType}`, + ); this.notificationsService.sendNotification( user.sub, 'Tax Strategy Created', diff --git a/src/users/pipes/filename-validation.pipe.ts b/src/users/pipes/filename-validation.pipe.ts index 659fab1f..60f6145b 100644 --- a/src/users/pipes/filename-validation.pipe.ts +++ b/src/users/pipes/filename-validation.pipe.ts @@ -11,9 +11,7 @@ export class FilenameValidationPipe implements PipeTransform { } if (value.length > MAX_FILENAME_LENGTH) { - throw new BadRequestException( - `Filename must not exceed ${MAX_FILENAME_LENGTH} characters`, - ); + throw new BadRequestException(`Filename must not exceed ${MAX_FILENAME_LENGTH} characters`); } if (value.includes('..') || value.includes('/') || value.includes('\\')) { diff --git a/src/utils/validate-env.ts b/src/utils/validate-env.ts index 5b1f8668..768bd281 100644 --- a/src/utils/validate-env.ts +++ b/src/utils/validate-env.ts @@ -1,8 +1,4 @@ -const REQUIRED_ENV_VARS = [ - 'DATABASE_URL', - 'JWT_SECRET', - 'JWT_REFRESH_SECRET', -] as const; +const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_SECRET', 'JWT_REFRESH_SECRET'] as const; export function validateEnvironment(): void { const MISSING: string[] = []; diff --git a/test/e2e/analytics-date-range.spec.ts b/test/e2e/analytics-date-range.spec.ts index 6b1a3bd8..48a3f2b8 100644 --- a/test/e2e/analytics-date-range.spec.ts +++ b/test/e2e/analytics-date-range.spec.ts @@ -18,7 +18,18 @@ describe('Analytics date range boundary (e2e)', () => { const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ TransactionsService, - { provide: PrismaService, useValue: { transaction: { findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0) }, $connect: jest.fn(), $disconnect: jest.fn(), $transaction: jest.fn((a: any) => Promise.all(a)) } }, + { + provide: PrismaService, + useValue: { + transaction: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + $connect: jest.fn(), + $disconnect: jest.fn(), + $transaction: jest.fn((a: any) => Promise.all(a)), + }, + }, { provide: BlockchainService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: CommissionsService, useValue: {} }, @@ -31,12 +42,12 @@ describe('Analytics date range boundary (e2e)', () => { service = moduleRef.get(TransactionsService); }); - it('should cap date range at maxDays when startDate and endDate exceed limit', async () => { + it('should reject date ranges exceeding maxDays', async () => { const startDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); const endDate = new Date(); - const result = await service.getAnalytics({ startDate, endDate, maxDays: 365 }); - expect(result).toBeDefined(); - expect(result.totalTransactions).toBe(0); + await expect(service.getAnalytics({ startDate, endDate, maxDays: 365 })).rejects.toThrow( + BadRequestException, + ); }); it('should cap date range at 365 days when only startDate is provided', async () => { diff --git a/test/users/avatar-upload.spec.ts b/test/users/avatar-upload.spec.ts index 71eb4ad5..bd2e7e9c 100644 --- a/test/users/avatar-upload.spec.ts +++ b/test/users/avatar-upload.spec.ts @@ -121,18 +121,15 @@ describe('AvatarUploadController', () => { const result = await controller.deleteAvatar(filename, { user: mockUser }); - expect(avatarUploadService.deleteAvatar).toHaveBeenCalledWith( - mockUser.id, - filename, - ); + expect(avatarUploadService.deleteAvatar).toHaveBeenCalledWith(mockUser.id, filename); expect(usersService.updateAvatar).toHaveBeenCalledWith(mockUser.id, null); expect(result).toEqual({ message: 'Avatar deleted successfully' }); }); it('should throw BadRequestException when user is not authenticated', async () => { - await expect( - controller.deleteAvatar('test.jpg', { user: null } as any), - ).rejects.toThrow(BadRequestException); + await expect(controller.deleteAvatar('test.jpg', { user: null } as any)).rejects.toThrow( + BadRequestException, + ); }); });