Skip to content
Merged
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Expand All @@ -16,7 +18,7 @@
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';

Check warning on line 21 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

'ApiQuery' is defined but never used

Check warning on line 21 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

'ApiOperation' is defined but never used
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
Expand Down Expand Up @@ -53,17 +55,17 @@
) {}

@Get('dashboard')
getDashboard() {

Check warning on line 58 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function

Check warning on line 58 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
return this.adminService.getDashboard();
}

@Get('backups')
listBackups() {

Check warning on line 63 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function

Check warning on line 63 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
return this.adminService.listBackups();
}

@Get('backups/status')
getBackupStatus() {

Check warning on line 68 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
return this.adminService.getBackupStatus();
}

Expand Down Expand Up @@ -274,4 +276,17 @@
note: 'This is a preview with sample data. Actual emails will use real data.',
};
}

@Delete('exports/:filename')
deleteExport(@Param('filename') filename: string) {
const filepath = path.join(process.cwd(), 'exports', filename);

if (!fs.existsSync(filepath)) {
throw new NotFoundException('Export file not found');
}

fs.unlinkSync(filepath);

return { message: 'Export file deleted successfully' };
}
}
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
20 changes: 12 additions & 8 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
comparePassword,
createSha256,
generateBackupCodes,
getPasswordHistoryLimit,
hashPassword,
parseDuration,
randomBase32Secret,
Expand Down Expand Up @@ -116,7 +115,7 @@ export class AuthService {
throw new BadRequestException('A user with that email already exists');
}

const passwordErrors = validatePassword(data.password);
const passwordErrors = validatePassword(data.password, this.configService);
if (passwordErrors.length > 0) {
throw new BadRequestException(
`Password does not meet complexity requirements: ${passwordErrors.join('; ')}`,
Expand Down Expand Up @@ -175,7 +174,7 @@ export class AuthService {
* 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 +204,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 Expand Up @@ -704,7 +703,7 @@ export class AuthService {
}

async changePassword(user: AuthUserPayload, data: ChangePasswordDto) {
const passwordHistoryLimit = getPasswordHistoryLimit();
const passwordHistoryLimit = this.getPasswordHistoryLimit();
const existingUser = await this.prisma.user.findUnique({
where: { id: user.sub },
include: {
Expand All @@ -726,7 +725,7 @@ export class AuthService {
throw new UnauthorizedException('Current password is incorrect');
}

const passwordErrors = validatePassword(data.newPassword);
const passwordErrors = validatePassword(data.newPassword, this.configService);
if (passwordErrors.length > 0) {
throw new BadRequestException(
`Password does not meet complexity requirements: ${passwordErrors.join('; ')}`,
Expand Down Expand Up @@ -1335,9 +1334,9 @@ export class AuthService {
throw new BadRequestException('Account is blocked');
}

const passwordHistoryLimit = getPasswordHistoryLimit();
const passwordHistoryLimit = this.getPasswordHistoryLimit();

const passwordErrors = validatePassword(data.newPassword);
const passwordErrors = validatePassword(data.newPassword, this.configService);
if (passwordErrors.length > 0) {
throw new BadRequestException(
`Password does not meet complexity requirements: ${passwordErrors.join('; ')}`,
Expand Down Expand Up @@ -1437,6 +1436,11 @@ export class AuthService {
});
}

private getPasswordHistoryLimit(): number {
const parsed = Number(this.configService.get('PASSWORD_HISTORY_LIMIT') ?? 5);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
}

private async verifyCaptcha(token: string): Promise<boolean> {
const secret = this.configService.get<string>('RECAPTCHA_SECRET');
if (!secret) {
Expand Down
20 changes: 11 additions & 9 deletions src/auth/password.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @ts-nocheck

import { ConfigService } from '@nestjs/config';

export type PasswordPolicy = {
minLength: number;
requireUppercase: boolean;
Expand All @@ -9,22 +11,22 @@ export type PasswordPolicy = {
specialChars?: string;
};

export function getPasswordPolicy(): PasswordPolicy {
const minLength = Number(process.env.PASSWORD_MIN_LENGTH ?? 8);
export function getPasswordPolicy(configService: ConfigService): PasswordPolicy {
const minLength = Number(configService.get('PASSWORD_MIN_LENGTH') ?? 8);
return {
minLength: Number.isFinite(minLength) && minLength > 0 ? minLength : 8,
requireUppercase: (process.env.PASSWORD_REQUIRE_UPPERCASE ?? 'true') === 'true',
requireLowercase: (process.env.PASSWORD_REQUIRE_LOWERCASE ?? 'true') === 'true',
requireDigit: (process.env.PASSWORD_REQUIRE_DIGIT ?? 'true') === 'true',
requireSpecial: (process.env.PASSWORD_REQUIRE_SPECIAL ?? 'true') === 'true',
requireUppercase: (configService.get('PASSWORD_REQUIRE_UPPERCASE') ?? 'true') === 'true',
requireLowercase: (configService.get('PASSWORD_REQUIRE_LOWERCASE') ?? 'true') === 'true',
requireDigit: (configService.get('PASSWORD_REQUIRE_DIGIT') ?? 'true') === 'true',
requireSpecial: (configService.get('PASSWORD_REQUIRE_SPECIAL') ?? 'true') === 'true',
specialChars:
process.env.PASSWORD_SPECIAL_CHARS ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32),
configService.get('PASSWORD_SPECIAL_CHARS') ?? '!@#$%^&*()_+-=[]{}|;:\",./<>?'.slice(0, 32),
};
}

export function validatePassword(password: string): string[] {
export function validatePassword(password: string, configService: ConfigService): string[] {
const errors: string[] = [];
const policy = getPasswordPolicy();
const policy = getPasswordPolicy(configService);

if (!password || password.length < policy.minLength) {
errors.push(`Password must be at least ${policy.minLength} characters long`);
Expand Down
5 changes: 0 additions & 5 deletions src/auth/security.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,6 @@ export function generateBackupCodes(count = 8): string[] {
return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase());
}

export function getPasswordHistoryLimit(): number {
const parsed = Number(process.env.PASSWORD_HISTORY_LIMIT ?? 5);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
}

export function verifyBackupCode(candidate: string, backupCodeHashes: string[]) {
const digest = createSha256(candidate.trim().toUpperCase());
const digestBuffer = Buffer.from(digest);
Expand Down
2 changes: 2 additions & 0 deletions src/blockchain/blockchain.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { BlockchainService } from './blockchain.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUserPayload } from '../auth/types/auth-user.type';
import {
RecordTransactionOnBlockchainDto,
BlockchainTransactionDto,
Expand Down
4 changes: 2 additions & 2 deletions src/documents/documents-download.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ export class DocumentsDownloadController {
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const doc = await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
const doc = await this.documentsService.findAuthorizedById(id, user.sub, user.role);

let targetFileUrl = doc.fileUrl;
if (query.versionId) {
const version = await this.documentsService.getVersion(
id,
query.versionId,
user.sub,
(user as any).role,
user.role,
);
targetFileUrl = version.fileUrl;
}
Expand Down
18 changes: 9 additions & 9 deletions src/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ export class DocumentsController {

@Get()
findAll(@CurrentUser() user: AuthUserPayload, @Query() filter: FilterDocumentsDto) {
return this.documentsService.findAll(user.sub, filter, (user as any).role);
return this.documentsService.findAll(user.sub, filter, user.role);
}

@Get(':id')
findOne(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) {
return this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
return this.documentsService.findAuthorizedById(id, user.sub, user.role);
}

@Put(':id')
Expand All @@ -56,21 +56,21 @@ export class DocumentsController {
@Body() dto: UpdateDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
await this.documentsService.findAuthorizedById(id, user.sub, user.role);
return this.documentsService.update(id, dto);
}

@Delete(':id')
async remove(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) {
await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
await this.documentsService.findAuthorizedById(id, user.sub, user.role);
return this.documentsService.remove(id);
}

// ── #572 Version History ─────────────────────────────────────────────────

@Get(':id/versions')
getVersions(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) {
return this.documentsService.getVersions(id, user.sub, (user as any).role);
return this.documentsService.getVersions(id, user.sub, user.role);
}

@Get(':id/versions/:versionId')
Expand All @@ -79,7 +79,7 @@ export class DocumentsController {
@Param('versionId') versionId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.documentsService.getVersion(id, versionId, user.sub, (user as any).role);
return this.documentsService.getVersion(id, versionId, user.sub, user.role);
}

// ── #402 Expiration ──────────────────────────────────────────────────────
Expand All @@ -105,7 +105,7 @@ export class DocumentsController {

@Put(':id/expiration/notified')
async flagNotified(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) {
await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
await this.documentsService.findAuthorizedById(id, user.sub, user.role);
return this.documentsService.flagExpiryNotified(id);
}

Expand All @@ -117,13 +117,13 @@ export class DocumentsController {
@Body() dto: SignDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
await this.documentsService.findAuthorizedById(id, user.sub, user.role);
return this.documentsService.signDocument(id, dto);
}

@Get(':id/verify')
async verify(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) {
await this.documentsService.findAuthorizedById(id, user.sub, (user as any).role);
await this.documentsService.findAuthorizedById(id, user.sub, user.role);
return this.documentsService.verifySignature(id);
}

Expand Down
2 changes: 0 additions & 2 deletions src/favorites/favorites.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import {
Controller,
Delete,
Expand Down
13 changes: 4 additions & 9 deletions src/transactions/transaction-notes.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { CreateNoteDto } from './dto/transaction-note.dto';
Expand All @@ -12,7 +10,7 @@ export class TransactionNotesService {
const tx = await this.prisma.transaction.findUnique({ where: { id: transactionId } });
if (!tx) throw new NotFoundException('Transaction not found');

return (this.prisma as any).transactionNote.create({
return this.prisma.transactionNote.create({
data: {
transactionId,
authorId,
Expand All @@ -31,15 +29,12 @@ export class TransactionNotesService {

const where: any = { transactionId };
if (!isPrivileged && !isParty) {
// Non-party/non-admin can only see public notes authored by themselves
where.isPublic = true;
} else if (!isPrivileged) {
// Transaction parties see public notes and their own private notes
where.OR = [{ isPublic: true }, { authorId: viewerId }];
}
// Admins/agents see all notes

return (this.prisma as any).transactionNote.findMany({
return this.prisma.transactionNote.findMany({
where,
orderBy: { createdAt: 'asc' },
include: {
Expand All @@ -49,14 +44,14 @@ export class TransactionNotesService {
}

async remove(noteId: string, requesterId: string, requesterRole: string) {
const note = await (this.prisma as any).transactionNote.findUnique({ where: { id: noteId } });
const note = await this.prisma.transactionNote.findUnique({ where: { id: noteId } });
if (!note) throw new NotFoundException('Note not found');

const isPrivileged = requesterRole === 'ADMIN';
if (note.authorId !== requesterId && !isPrivileged) {
throw new ForbiddenException('Only the author or admin can delete this note');
}

return (this.prisma as any).transactionNote.delete({ where: { id: noteId } });
return this.prisma.transactionNote.delete({ where: { id: noteId } });
}
}
2 changes: 0 additions & 2 deletions src/transactions/transactions.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import {
Controller,
Delete,
Expand Down
27 changes: 22 additions & 5 deletions src/transactions/transactions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,31 @@ describe('TransactionsService', () => {
expect(result.volumeTrends).toEqual([]);
});

it('should reject date ranges larger than 365 days', async () => {
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);

await expect(
service.getAnalytics({ startDate, endDate, granularity: TransactionAnalyticsGranularity.MONTH }),
).rejects.toThrow(BadRequestException);
expect(prisma.transaction.findMany).not.toHaveBeenCalled();
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,
}),
}),
}),
);
expect(result.totalTransactions).toBe(0);
});
});
});
8 changes: 1 addition & 7 deletions src/transactions/transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,15 +318,9 @@ export class TransactionsService {
const maxDays = query.maxDays ?? 365;

if (query.startDate && query.endDate) {
const maxRangeMs = 365 * 24 * 60 * 60 * 1000;
const durationMs = query.endDate.getTime() - query.startDate.getTime();

if (durationMs < 0) {
if (query.endDate.getTime() < query.startDate.getTime()) {
throw new BadRequestException('endDate must be on or after startDate');
}
if (durationMs > maxRangeMs) {
throw new BadRequestException('Date range cannot exceed 365 days');
}
}

if (query.type) {
Expand Down
Loading
Loading