Skip to content
Open
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ DB_PASSWORD=postgres
DB_NAME=interchangabletrade
DB_SYNCHRONIZE=true
DB_LOGGING=false
DB_POOL_MAX=20
DB_POOL_MIN=5
DB_SSL=false
DB_MIGRATIONS_DIR=src/migrations
DB_BACKUP_ENABLED=false
DB_BACKUP_INTERVAL_MIN=60
DB_BACKUP_RETENTION_DAYS=7
DB_SHARDING_ENABLED=false
DB_SHARD_COUNT=1

# Redis
REDIS_HOST=localhost
Expand Down
6 changes: 5 additions & 1 deletion libs/common/src/dto/paginated-result.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export class PaginatedResultDto<T> {
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};

constructor(data: T[], total: number, page: number, limit: number) {
Expand All @@ -22,7 +24,9 @@ export class PaginatedResultDto<T> {
total,
page,
limit,
totalPages: Math.ceil(total / limit) || 0,
totalPages: Math.ceil(total / limit) || 1,
hasNextPage: page < (Math.ceil(total / limit) || 1),
hasPreviousPage: page > 1,
};
}
}
61 changes: 51 additions & 10 deletions libs/common/src/dto/pagination-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,68 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsInt, Min, Max, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

/**
* Reusable pagination query for list endpoints. `page` is 1-based.
*/
export enum SortDirection {
ASC = 'ASC',
DESC = 'DESC',
}

export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
page?: number = 1;

@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
limit?: number = 20;

@IsOptional()
@IsString()
search?: string;

@IsOptional()
@IsString()
sortBy?: string;

@IsOptional()
@IsEnum(SortDirection)
sortDirection?: SortDirection = SortDirection.DESC;

get skip(): number {
return (this.page - 1) * this.limit;
return ((this.page ?? 1) - 1) * (this.limit ?? 20);
}
}

export class PaginatedResultDto<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};

constructor(data: T[], total: number, page: number, limit: number) {
this.data = data;
this.meta = {
total,
page,
limit,
totalPages: Math.ceil(total / limit) || 1,
hasNextPage: page < (Math.ceil(total / limit) || 1),
hasPreviousPage: page > 1,
};
}
}

export class BulkOperationResult {
success: boolean;
affectedCount: number;
errors: Array<{ index: number; error: string }>;
}
2 changes: 1 addition & 1 deletion libs/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
export * from './entities/base.entity';
export * from './dto/pagination-query.dto';
export * from './dto/paginated-result.dto';
export { PaginatedResultDto } from './dto/paginated-result.dto';
export * from './interceptors/transform.interceptor';
export * from './filters/http-exception.filter';
export * from './decorators/api-paginated-response.decorator';
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';
import { DatabaseConfig } from './config/database.config';
import { DatabaseModule } from './modules/database/database.module';
import { RedisModule } from './redis/redis.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
Expand All @@ -20,6 +21,7 @@ import { WalletModule } from './modules/wallet/wallet.module';
TypeOrmModule.forRootAsync({
useClass: DatabaseConfig,
}),
DatabaseModule,
RedisModule,
UsersModule,
AuthModule,
Expand Down
9 changes: 9 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export default () => ({
name: process.env.DB_NAME,
synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: process.env.DB_LOGGING === 'true',
poolMax: parseInt(process.env.DB_POOL_MAX ?? '20', 10),
poolMin: parseInt(process.env.DB_POOL_MIN ?? '5', 10),
ssl: process.env.DB_SSL === 'true',
migrationsDir: process.env.DB_MIGRATIONS_DIR ?? 'src/migrations',
backupEnabled: process.env.DB_BACKUP_ENABLED === 'true',
backupIntervalMin: parseInt(process.env.DB_BACKUP_INTERVAL_MIN ?? '60', 10),
backupRetentionDays: parseInt(process.env.DB_BACKUP_RETENTION_DAYS ?? '7', 10),
shardingEnabled: process.env.DB_SHARDING_ENABLED === 'true',
shardCount: parseInt(process.env.DB_SHARD_COUNT ?? '1', 10),
},

redis: {
Expand Down
26 changes: 23 additions & 3 deletions src/config/database.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
TypeOrmModuleOptions,
Expand All @@ -9,14 +9,19 @@ import {
* Builds TypeORM connection options from validated configuration. Entities are
* auto-loaded via the `autoLoadEntities` flag so feature modules only need to
* register their entities with `TypeOrmModule.forFeature`.
*
* Includes connection pool tuning, SSL, and structured logging for production
* observability.
*/
@Injectable()
export class DatabaseConfig implements TypeOrmOptionsFactory {
constructor(private readonly configService: ConfigService) {}

createTypeOrmOptions(): TypeOrmModuleOptions {
const db = this.configService.get('database');
return {
const logger = new Logger('DatabaseConfig');

const options: TypeOrmModuleOptions = {
type: 'postgres',
host: db.host,
port: db.port,
Expand All @@ -25,7 +30,22 @@ export class DatabaseConfig implements TypeOrmOptionsFactory {
database: db.name,
autoLoadEntities: true,
synchronize: db.synchronize,
logging: db.logging,
logging: db.logging ? ['query', 'error', 'schema', 'migration'] : false,
extra: {
ssl: db.ssl ? { rejectUnauthorized: false } : undefined,
max: db.poolMax,
min: db.poolMin,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
},
};

if (db.synchronize) {
logger.warn(
'Database synchronize is enabled. This should NEVER be used in production.',
);
}

return options;
}
}
9 changes: 9 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export const envValidationSchema = Joi.object({
DB_NAME: Joi.string().required(),
DB_SYNCHRONIZE: Joi.boolean().default(false),
DB_LOGGING: Joi.boolean().default(false),
DB_POOL_MAX: Joi.number().default(20),
DB_POOL_MIN: Joi.number().default(5),
DB_SSL: Joi.boolean().default(false),
DB_MIGRATIONS_DIR: Joi.string().default('src/migrations'),
DB_BACKUP_ENABLED: Joi.boolean().default(false),
DB_BACKUP_INTERVAL_MIN: Joi.number().default(60),
DB_BACKUP_RETENTION_DAYS: Joi.number().default(7),
DB_SHARDING_ENABLED: Joi.boolean().default(false),
DB_SHARD_COUNT: Joi.number().default(1),

REDIS_HOST: Joi.string().default('localhost'),
REDIS_PORT: Joi.number().default(6379),
Expand Down
2 changes: 1 addition & 1 deletion src/modules/assets/assets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class AssetsService {
}

const [data, total] = await qb.getManyAndCount();
return new PaginatedResultDto(data, total, query.page, query.limit);
return new PaginatedResultDto(data, total, query.page ?? 1, query.limit ?? 20);
}

async findOne(id: string): Promise<Asset> {
Expand Down
147 changes: 147 additions & 0 deletions src/modules/database/audit-trail.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, EntityManager } from 'typeorm';
import { AuditTrailService, AuditAction } from './audit-trail.service';

describe('AuditTrailService', () => {
let service: AuditTrailService;
let mockDataSource: jest.Mocked<DataSource>;

beforeEach(async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
};

mockDataSource = {
createQueryRunner: jest.fn(),
manager: {
insert: jest.fn().mockResolvedValue(undefined),
query: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
} as any,
} as any;

const module: TestingModule = await Test.createTestingModule({
providers: [
AuditTrailService,
{
provide: DataSource,
useValue: mockDataSource,
},
],
}).compile();

service = module.get(AuditTrailService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('isAudited', () => {
it('should return true for audited tables', () => {
expect(service.isAudited('users')).toBe(true);
expect(service.isAudited('wallets')).toBe(true);
expect(service.isAudited('orders')).toBe(true);
});

it('should return false for non-audited tables', () => {
expect(service.isAudited('unknown_table')).toBe(false);
expect(service.isAudited('test')).toBe(false);
});
});

describe('registerAuditedTable', () => {
it('should register new audited table', () => {
service.registerAuditedTable('custom_table');

expect(service.isAudited('custom_table')).toBe(true);
});
});

describe('log', () => {
it('should log audit entry for audited table', async () => {
const mockQueryRunner = {
connect: jest.fn().mockResolvedValue(undefined),
startTransaction: jest.fn().mockResolvedValue(undefined),
commitTransaction: jest.fn().mockResolvedValue(undefined),
rollbackTransaction: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
isTransactionActive: true,
manager: {
insert: jest.fn().mockResolvedValue(undefined),
},
};

mockDataSource.createQueryRunner = jest.fn().mockReturnValue(mockQueryRunner as any);

await service.log({
entityType: 'users',
entityId: '123',
action: AuditAction.CREATED,
newState: { name: 'John' },
});

expect(mockQueryRunner.manager.insert).toHaveBeenCalled();
});

it('should not log for non-audited table', async () => {
await service.log({
entityType: 'non_audited',
entityId: '123',
action: AuditAction.CREATED,
});

expect(mockDataSource.createQueryRunner).not.toHaveBeenCalled();
});
});

describe('findAuditTrail', () => {
it('should return audit trail for entity', async () => {
const mockAuditTrails = [
{ id: 1, action: 'created' },
{ id: 2, action: 'updated' },
];

const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue(mockAuditTrails),
};

mockDataSource.manager.createQueryBuilder = jest.fn().mockReturnValue(mockQueryBuilder);

const result = await service.findAuditTrail('users', '123', 10);

expect(result).toEqual(mockAuditTrails);
});
});

describe('getEntityHistory', () => {
it('should return entity history', async () => {
const mockHistory = [
{ id: 1, entityId: '123', action: 'created' },
{ id: 2, entityId: '123', action: 'updated' },
];

mockDataSource.manager.query = jest.fn().mockResolvedValue(mockHistory);

const result = await service.getEntityHistory('123', 100);

expect(result).toEqual(mockHistory);
expect(mockDataSource.manager.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM audit_trails'),
['123', 100],
);
});
});
});
Loading
Loading