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
24 changes: 18 additions & 6 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,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 @@ -168,13 +172,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, ipAddress?: string, userAgent?: string): 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 @@ -1245,6 +1253,10 @@ export class AuthService {
});
}

/**
* Generate a new API key value with 'pc_' prefix and 24 random characters.
* Format: pc_<24-char-random-hex>
*/
private generateApiKeyValue() {
return `pc_${randomToken(24)}`;
}
Expand Down
3 changes: 3 additions & 0 deletions src/auth/guards/rate-limit.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export class RateLimitGuard implements CanActivate {
const endpoint = `${request.method} ${request.route?.path || request.url}`;

try {
// Check by user if authenticated
// Tier defaults to 'free' as it is not included in the current JWT payload.
// When 'tier' is added to JwtPayload, this logic will use the actual value.
const ip = this.getClientIp(request);

if (request.user?.id) {
Expand Down
15 changes: 15 additions & 0 deletions src/common/common.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
TransactionStatus,
DocumentType,
VerificationStatus,
FraudSeverity,
FraudStatus,
FraudPattern,
DisputeStatus,
MilestoneStatus,
} from '@prisma/client';

registerEnumType(UserRole, { name: 'UserRole' });
Expand All @@ -16,6 +21,11 @@ registerEnumType(TransactionType, { name: 'TransactionType' });
registerEnumType(TransactionStatus, { name: 'TransactionStatus' });
registerEnumType(DocumentType, { name: 'DocumentType' });
registerEnumType(VerificationStatus, { name: 'VerificationStatus' });
registerEnumType(FraudSeverity, { name: 'FraudSeverity' });
registerEnumType(FraudStatus, { name: 'FraudStatus' });
registerEnumType(FraudPattern, { name: 'FraudPattern' });
registerEnumType(DisputeStatus, { name: 'DisputeStatus' });
registerEnumType(MilestoneStatus, { name: 'MilestoneStatus' });

export {
UserRole,
Expand All @@ -24,4 +34,9 @@ export {
TransactionStatus,
DocumentType,
VerificationStatus,
FraudSeverity,
FraudStatus,
FraudPattern,
DisputeStatus,
MilestoneStatus,
};
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
28 changes: 7 additions & 21 deletions src/transactions/transactions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,28 +259,14 @@ describe('TransactionsService', () => {
it('should cap date ranges larger than maxDays', async () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2026-01-02T00:00:00.000Z');
const cappedEnd = new Date(startDate);
cappedEnd.setDate(cappedEnd.getDate() + 365);

mockPrismaService.transaction.findMany.mockResolvedValue([]);

const result = await service.getAnalytics({
startDate,
endDate,
granularity: TransactionAnalyticsGranularity.MONTH,
});

expect(prisma.transaction.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
createdAt: expect.objectContaining({
gte: startDate,
lte: cappedEnd,
}),
}),
await expect(
service.getAnalytics({
startDate,
endDate,
granularity: TransactionAnalyticsGranularity.MONTH,
}),
);
expect(result.totalTransactions).toBe(0);
).rejects.toThrow(BadRequestException);
expect(prisma.transaction.findMany).not.toHaveBeenCalled();
});
});
});
19 changes: 9 additions & 10 deletions src/transactions/transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,11 @@ export class TransactionsService {
if (query.endDate.getTime() < query.startDate.getTime()) {
throw new BadRequestException('endDate must be on or after startDate');
}
const maxRangeMs = (query.maxDays ?? 365) * 24 * 60 * 60 * 1000;
const durationMs = query.endDate.getTime() - query.startDate.getTime();
if (durationMs > maxRangeMs) {
throw new BadRequestException(`Date range cannot exceed ${query.maxDays ?? 365} days`);
}
}

if (query.type) {
Expand All @@ -332,15 +337,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 @@ -534,7 +531,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
Loading
Loading