Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/auth/auth.service.captcha.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ describe('AuthService – CAPTCHA failure lockout', () => {
password: '$2b$10$invalidhash',
isBlocked: false,
isDeactivated: false,
isVerified: true,
twoFactorEnabled: false,
});

Expand Down
22 changes: 15 additions & 7 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h',
24 * 60 * 60,
) * 1000);
const verificationExpiresAt = new Date(
Date.now() +
parseDuration(
this.configService.get<string>('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h',
24 * 60 * 60,
) *
1000,
);

const user = await this.prisma.user.create({
data: {
Expand Down Expand Up @@ -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<void> {
private async preflightChecks(
data: LoginDto,
ipAddress?: string,
userAgent?: string,
): Promise<void> {
// Check if account is locked out
const isLocked = await this.rateLimitService.isAccountLocked(data.email);
if (isLocked) {
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<DashboardDto> {
this.logger.log(`Fetching dashboard for user ${userId}`);
const [profile, stats, recentActivity, recommendations] = await Promise.all([
this.getProfileSummary(userId),
this.getQuickStats(userId),
Expand Down
13 changes: 12 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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}`);
Expand Down
22 changes: 16 additions & 6 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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(),
]);
}),
);
Expand Down
22 changes: 19 additions & 3 deletions src/transactions/dto/transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion src/transactions/transactions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
14 changes: 4 additions & 10 deletions src/transactions/transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 1 addition & 3 deletions src/users/pipes/filename-validation.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ export class FilenameValidationPipe implements PipeTransform<string, string> {
}

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('\\')) {
Expand Down
6 changes: 1 addition & 5 deletions src/utils/validate-env.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down
21 changes: 16 additions & 5 deletions test/e2e/analytics-date-range.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand All @@ -31,12 +42,12 @@ describe('Analytics date range boundary (e2e)', () => {
service = moduleRef.get<TransactionsService>(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 () => {
Expand Down
11 changes: 4 additions & 7 deletions test/users/avatar-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});

Expand Down
Loading