diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index a25f684c..d4f85493 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -24,20 +24,25 @@ export class AdminService { this.prisma.property.count({ where: { status: PropertyStatus.ACTIVE } }), ]); - const [completedTransactions, pendingTransactions, failedTransactions, salesAggregate, rentAggregate] = - await Promise.all([ - this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), - this.prisma.transaction.count({ where: { status: 'PENDING' } }), - this.prisma.transaction.count({ where: { status: 'FAILED' } }), - this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'SALE' }, - _sum: { amount: true }, - }), - this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'TRANSFER' }, - _sum: { amount: true }, - }), - ]); + const [ + completedTransactions, + pendingTransactions, + failedTransactions, + salesAggregate, + rentAggregate, + ] = await Promise.all([ + this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), + this.prisma.transaction.count({ where: { status: 'PENDING' } }), + this.prisma.transaction.count({ where: { status: 'FAILED' } }), + this.prisma.transaction.aggregate({ + where: { status: 'COMPLETED', type: 'SALE' }, + _sum: { amount: true }, + }), + this.prisma.transaction.aggregate({ + where: { status: 'COMPLETED', type: 'TRANSFER' }, + _sum: { amount: true }, + }), + ]); return { userStats: { @@ -198,7 +203,9 @@ export class AdminService { async bulkModerate(payload: BulkModerationDto) { const status = - payload.action === BulkModerationAction.APPROVE ? PropertyStatus.ACTIVE : PropertyStatus.ARCHIVED; + payload.action === BulkModerationAction.APPROVE + ? PropertyStatus.ACTIVE + : PropertyStatus.ARCHIVED; const result = await this.prisma.property.updateMany({ where: { id: { in: payload.propertyIds } }, diff --git a/src/admin/dto/admin.dto.ts b/src/admin/dto/admin.dto.ts index 6eb8a9b0..a3d65040 100644 --- a/src/admin/dto/admin.dto.ts +++ b/src/admin/dto/admin.dto.ts @@ -1,6 +1,21 @@ import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; -import { PropertyStatus, TransactionStatus, TransactionType, UserRole } from '../../types/prisma.types'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; +import { + PropertyStatus, + TransactionStatus, + TransactionType, + UserRole, +} from '../../types/prisma.types'; export class AdminUsersQueryDto { @IsOptional() diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts new file mode 100644 index 00000000..26289b66 --- /dev/null +++ b/src/analytics/analytics.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Delete, UseGuards } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../types/prisma.types'; + +@Controller('analytics') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AnalyticsController { + constructor(private readonly analytics: AnalyticsService) {} + + @Get() + getStats() { + return this.analytics.getStats(); + } + + @Delete('reset') + reset() { + this.analytics.reset(); + return { message: 'Analytics reset' }; + } +} diff --git a/src/analytics/analytics.interceptor.ts b/src/analytics/analytics.interceptor.ts new file mode 100644 index 00000000..1a3fe39a --- /dev/null +++ b/src/analytics/analytics.interceptor.ts @@ -0,0 +1,26 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AnalyticsService } from './analytics.service'; + +@Injectable() +export class AnalyticsInterceptor implements NestInterceptor { + constructor(private readonly analytics: AnalyticsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + const start = Date.now(); + + return next.handle().pipe( + tap(() => { + this.analytics.record({ + endpoint: req.path, + method: req.method, + statusCode: res.statusCode, + responseTime: Date.now() - start, + }); + }), + ); + } +} diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts new file mode 100644 index 00000000..a8bc8cb5 --- /dev/null +++ b/src/analytics/analytics.module.ts @@ -0,0 +1,12 @@ +import { Module, Global } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsInterceptor } from './analytics.interceptor'; + +@Global() +@Module({ + controllers: [AnalyticsController], + providers: [AnalyticsService, AnalyticsInterceptor], + exports: [AnalyticsService, AnalyticsInterceptor], +}) +export class AnalyticsModule {} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts new file mode 100644 index 00000000..53feaa50 --- /dev/null +++ b/src/analytics/analytics.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +interface RequestRecord { + endpoint: string; + method: string; + statusCode: number; + responseTime: number; + timestamp: Date; +} + +@Injectable() +export class AnalyticsService { + private records: RequestRecord[] = []; + private readonly MAX_RECORDS = 10000; + + record(data: Omit): void { + this.records.push({ ...data, timestamp: new Date() }); + if (this.records.length > this.MAX_RECORDS) { + this.records.shift(); + } + } + + getStats() { + const total = this.records.length; + if (total === 0) return { total: 0, endpoints: [], errors: [], avgResponseTime: 0 }; + + const endpointMap = new Map(); + const errorMap = new Map(); + let totalTime = 0; + + for (const r of this.records) { + const key = `${r.method} ${r.endpoint}`; + const ep = endpointMap.get(key) ?? { count: 0, totalTime: 0 }; + ep.count++; + ep.totalTime += r.responseTime; + endpointMap.set(key, ep); + + totalTime += r.responseTime; + + if (r.statusCode >= 400) { + errorMap.set(r.statusCode, (errorMap.get(r.statusCode) ?? 0) + 1); + } + } + + const endpoints = [...endpointMap.entries()] + .map(([endpoint, { count, totalTime: t }]) => ({ + endpoint, + count, + avgResponseTime: Math.round(t / count), + })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const errors = [...errorMap.entries()].map(([statusCode, count]) => ({ + statusCode, + count, + })); + + return { + total, + avgResponseTime: Math.round(totalTime / total), + endpoints, + errors, + }; + } + + reset(): void { + this.records = []; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index ff37fd08..e0ea1e83 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,8 @@ import { PrismaModule } from './database/prisma.module'; import { VersioningModule } from './versioning/versioning.module'; import { ApiDocumentationModule } from './config/api-documentation.module'; import { CacheModuleConfig } from './cache/cache.module'; +import { AnalyticsModule } from './analytics/analytics.module'; +import { IntegrationsModule } from './integrations/integrations.module'; import { AppController } from './app.controller'; import { AdminModule } from './admin/admin.module'; @@ -21,6 +23,7 @@ import { AdminModule } from './admin/admin.module'; envFilePath: ['.env.local', '.env'], }), CacheModuleConfig, + AnalyticsModule, PrismaModule, VersioningModule, ApiDocumentationModule, @@ -32,6 +35,7 @@ import { AdminModule } from './admin/admin.module'; PropertiesModule, AdminModule, DocumentsModule, + IntegrationsModule, ], controllers: [AppController], }) diff --git a/src/auth/controllers/rate-limit-admin.controller.ts b/src/auth/controllers/rate-limit-admin.controller.ts index 3d241cf1..f079cbaa 100644 --- a/src/auth/controllers/rate-limit-admin.controller.ts +++ b/src/auth/controllers/rate-limit-admin.controller.ts @@ -39,9 +39,7 @@ export class RateLimitAdminController { }, }, }) - async getUserRateLimitStatus( - @Param('userId') userId: string, - ): Promise { + async getUserRateLimitStatus(@Param('userId') userId: string): Promise { return this.rateLimitService.getUserRateLimitStats(userId); } @@ -55,9 +53,7 @@ export class RateLimitAdminController { status: 200, description: 'Endpoint rate limit status retrieved successfully', }) - async getEndpointRateLimitStatus( - @Param('endpoint') endpoint: string, - ): Promise { + async getEndpointRateLimitStatus(@Param('endpoint') endpoint: string): Promise { return this.rateLimitService.checkEndpointRateLimit(endpoint); } @@ -100,9 +96,7 @@ export class RateLimitAdminController { status: 204, description: 'Endpoint rate limit reset successfully', }) - async resetEndpointRateLimit( - @Param('endpoint') endpoint: string, - ): Promise { + async resetEndpointRateLimit(@Param('endpoint') endpoint: string): Promise { return this.rateLimitService.resetEndpointRateLimit(endpoint); } @@ -124,8 +118,7 @@ export class RateLimitAdminController { @SkipRateLimit() @ApiOperation({ summary: 'Get rate limiting summary', - description: - 'Retrieve information about all configured rate limits and their current status', + description: 'Retrieve information about all configured rate limits and their current status', }) @ApiResponse({ status: 200, diff --git a/src/auth/decorators/rate-limit.decorator.ts b/src/auth/decorators/rate-limit.decorator.ts index 2ccb3eff..5010a051 100644 --- a/src/auth/decorators/rate-limit.decorator.ts +++ b/src/auth/decorators/rate-limit.decorator.ts @@ -11,10 +11,7 @@ export function RateLimited(options?: { by?: 'user' | 'ip' | 'apiKey'; }) { if (options) { - return applyDecorators( - UseGuards(RateLimitGuard), - CustomRateLimit(options), - ); + return applyDecorators(UseGuards(RateLimitGuard), CustomRateLimit(options)); } return UseGuards(RateLimitGuard); } diff --git a/src/auth/examples/rate-limit.examples.ts b/src/auth/examples/rate-limit.examples.ts index 7286a5e1..63ecbfcc 100644 --- a/src/auth/examples/rate-limit.examples.ts +++ b/src/auth/examples/rate-limit.examples.ts @@ -1,17 +1,11 @@ /** * Rate Limiting Usage Examples - * + * * This file demonstrates how to use the rate limiting features * in the PropChain API */ -import { - Controller, - Get, - Post, - Body, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { @@ -156,29 +150,29 @@ export class RateLimitExamplesController { /** * RATE LIMIT HEADERS IN RESPONSES - * + * * All rate-limited endpoints include these headers: - * + * * X-RateLimit-Limit: 100 // Max requests allowed in window * X-RateLimit-Remaining: 99 // Requests remaining in window * X-RateLimit-Reset: 1703088000 // Unix timestamp when limit resets - * + * * On rate limit exceeded (429 response): * Retry-After: 45 // Seconds to wait before retrying */ /** * RATE LIMIT STATUSES - * + * * 1. User-based rate limiting (authenticated requests) * - Free tier: 100 req/hour, 10k req/month * - Premium tier: 5000 req/hour, 500k req/month * - Enterprise tier: 50k req/hour, unlimited/month * - API Key: 10k req/hour, 1M req/month - * + * * 2. IP-based rate limiting (unauthenticated requests) * - 1000 requests per 15 minutes per IP - * + * * 3. Endpoint-specific rate limiting * - Authentication: 5 req/15 min * - User creation: 10 req/hour @@ -188,7 +182,7 @@ export class RateLimitExamplesController { /** * ERROR RESPONSE (429 Too Many Requests) - * + * * { * "statusCode": 429, * "message": "Rate limit exceeded. Max 100 requests per 15 minutes.", @@ -200,7 +194,7 @@ export class RateLimitExamplesController { /** * ADMIN ENDPOINTS FOR RATE LIMIT MANAGEMENT - * + * * GET /admin/rate-limits/user/:userId - Get user rate limit status * GET /admin/rate-limits/endpoint/:endpoint - Get endpoint rate limit status * GET /admin/rate-limits/summary - Get all rate limits summary diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts index 5e0501b9..6df22e04 100644 --- a/src/auth/guards/rate-limit.guard.ts +++ b/src/auth/guards/rate-limit.guard.ts @@ -16,8 +16,7 @@ export const RATE_LIMIT_CUSTOM_KEY = 'rate-limit-custom'; /** * Decorator to skip rate limiting for a route */ -export const SkipRateLimit = () => - Reflect.metadata(RATE_LIMIT_SKIP_KEY, true); +export const SkipRateLimit = () => Reflect.metadata(RATE_LIMIT_SKIP_KEY, true); /** * Decorator to apply custom rate limiting @@ -37,10 +36,10 @@ export class RateLimitGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { // Check if rate limiting is skipped for this route - const skip = this.reflector.getAllAndOverride( - RATE_LIMIT_SKIP_KEY, - [context.getHandler(), context.getClass()], - ); + const skip = this.reflector.getAllAndOverride(RATE_LIMIT_SKIP_KEY, [ + context.getHandler(), + context.getClass(), + ]); if (skip) { return true; @@ -60,11 +59,9 @@ export class RateLimitGuard implements CanActivate { ); // Apply rate limit headers - Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach( - ([key, value]) => { - response.setHeader(key, value); - }, - ); + Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach(([key, value]) => { + response.setHeader(key, value); + }); if (userStatus.isExceeded) { throw new HttpException( @@ -85,11 +82,9 @@ export class RateLimitGuard implements CanActivate { const ipStatus = await this.rateLimitService.checkIpRateLimit(ip); // Apply rate limit headers - Object.entries(this.rateLimitService.getHeaders(ipStatus)).forEach( - ([key, value]) => { - response.setHeader(key, value); - }, - ); + Object.entries(this.rateLimitService.getHeaders(ipStatus)).forEach(([key, value]) => { + response.setHeader(key, value); + }); if (ipStatus.isExceeded) { throw new HttpException( @@ -107,16 +102,12 @@ export class RateLimitGuard implements CanActivate { } // Check endpoint-specific limits - const endpointStatus = await this.rateLimitService.checkEndpointRateLimit( - endpoint, - ); + const endpointStatus = await this.rateLimitService.checkEndpointRateLimit(endpoint); if (endpointStatus.limit > 0) { - Object.entries(this.rateLimitService.getHeaders(endpointStatus)).forEach( - ([key, value]) => { - response.setHeader(key, value); - }, - ); + Object.entries(this.rateLimitService.getHeaders(endpointStatus)).forEach(([key, value]) => { + response.setHeader(key, value); + }); if (endpointStatus.isExceeded) { throw new HttpException( diff --git a/src/auth/interceptors/rate-limit-headers.interceptor.ts b/src/auth/interceptors/rate-limit-headers.interceptor.ts index 37ab3ca1..75fcc4ce 100644 --- a/src/auth/interceptors/rate-limit-headers.interceptor.ts +++ b/src/auth/interceptors/rate-limit-headers.interceptor.ts @@ -1,9 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { RATE_LIMIT_HEADERS } from '../rate-limit.config'; diff --git a/src/auth/modules/rate-limit.module.ts b/src/auth/modules/rate-limit.module.ts index c054eaf4..8712a594 100644 --- a/src/auth/modules/rate-limit.module.ts +++ b/src/auth/modules/rate-limit.module.ts @@ -8,10 +8,6 @@ import { RateLimitHeadersInterceptor } from '../interceptors/rate-limit-headers. @Module({ providers: [RateLimitService, RateLimitGuard, RateLimitHeadersInterceptor], controllers: [RateLimitAdminController], - exports: [ - RateLimitService, - RateLimitGuard, - RateLimitHeadersInterceptor, - ], + exports: [RateLimitService, RateLimitGuard, RateLimitHeadersInterceptor], }) export class RateLimitModule {} diff --git a/src/auth/rate-limit.service.ts b/src/auth/rate-limit.service.ts index f99993c8..05520950 100644 --- a/src/auth/rate-limit.service.ts +++ b/src/auth/rate-limit.service.ts @@ -107,18 +107,10 @@ export class RateLimitService { // Store the updated record if (!isExceeded) { - await this.cacheManager.set( - key, - { count, resetAt, windowMs }, - resetAt - now, - ); + await this.cacheManager.set(key, { count, resetAt, windowMs }, resetAt - now); } else { // Still store the count for tracking - await this.cacheManager.set( - key, - { count, resetAt, windowMs }, - resetAt - now, - ); + await this.cacheManager.set(key, { count, resetAt, windowMs }, resetAt - now); } const remaining = Math.max(0, limit - count); diff --git a/src/cache/cache-headers.interceptor.ts b/src/cache/cache-headers.interceptor.ts new file mode 100644 index 00000000..aab25e3d --- /dev/null +++ b/src/cache/cache-headers.interceptor.ts @@ -0,0 +1,18 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class CacheHeadersInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const res = context.switchToHttp().getResponse(); + const start = Date.now(); + + return next.handle().pipe( + tap(() => { + res.setHeader('X-Cache-Time', `${Date.now() - start}ms`); + res.setHeader('Cache-Control', 'public, max-age=60'); + }), + ); + } +} diff --git a/src/cache/cache-monitoring.service.ts b/src/cache/cache-monitoring.service.ts index 8c502606..99db1db8 100644 --- a/src/cache/cache-monitoring.service.ts +++ b/src/cache/cache-monitoring.service.ts @@ -65,14 +65,11 @@ export class CacheMonitoringService { */ getMetrics(): CacheMetrics { const hitRate = - this.metrics.totalRequests > 0 - ? (this.metrics.hits / this.metrics.totalRequests) * 100 - : 0; + this.metrics.totalRequests > 0 ? (this.metrics.hits / this.metrics.totalRequests) * 100 : 0; const avgResponseTime = this.metrics.responseTimes.length > 0 - ? this.metrics.responseTimes.reduce((a, b) => a + b, 0) / - this.metrics.responseTimes.length + ? this.metrics.responseTimes.reduce((a, b) => a + b, 0) / this.metrics.responseTimes.length : 0; return { @@ -125,7 +122,7 @@ export class CacheMonitoringService { const metrics = this.getMetrics(); this.logger.log( `Cache Performance - Hits: ${metrics.hits}, Misses: ${metrics.misses}, ` + - `Hit Rate: ${metrics.hitRate}%, Avg Response: ${metrics.avgResponseTime}ms`, + `Hit Rate: ${metrics.hitRate}%, Avg Response: ${metrics.avgResponseTime}ms`, ); } } diff --git a/src/cache/cache-stats.controller.ts b/src/cache/cache-stats.controller.ts new file mode 100644 index 00000000..35a0d110 --- /dev/null +++ b/src/cache/cache-stats.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Delete, UseGuards } from '@nestjs/common'; +import { CacheMonitoringService } from './cache-monitoring.service'; +import { CacheService } from './cache.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../types/prisma.types'; + +@Controller('cache') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class CacheStatsController { + constructor( + private readonly monitoring: CacheMonitoringService, + private readonly cacheService: CacheService, + ) {} + + @Get('stats') + getStats() { + return this.monitoring.getMetrics(); + } + + @Delete('clear') + async clearCache() { + await this.cacheService.clear(); + this.monitoring.resetMetrics(); + return { message: 'Cache cleared' }; + } +} diff --git a/src/cache/cache-warming.service.ts b/src/cache/cache-warming.service.ts index 5f694bab..befa067c 100644 --- a/src/cache/cache-warming.service.ts +++ b/src/cache/cache-warming.service.ts @@ -58,11 +58,7 @@ export class CacheWarmingService implements OnModuleInit { */ async warmUserCache(userId: string, userData: any): Promise { try { - await this.cacheService.set( - CACHE_KEYS.USER_BY_ID(userId), - userData, - CACHE_TTL.USER_PROFILE, - ); + await this.cacheService.set(CACHE_KEYS.USER_BY_ID(userId), userData, CACHE_TTL.USER_PROFILE); this.logger.debug(`User cache warmed for ${userId}`); } catch (error) { this.logger.error(`Error warming user cache for ${userId}:`, error); @@ -72,10 +68,7 @@ export class CacheWarmingService implements OnModuleInit { /** * Warm dashboard cache */ - async warmDashboardCache( - userId: string, - dashboardData: any, - ): Promise { + async warmDashboardCache(userId: string, dashboardData: any): Promise { try { await this.cacheService.set( CACHE_KEYS.DASHBOARD_STATS(userId), @@ -107,9 +100,7 @@ export class CacheWarmingService implements OnModuleInit { /** * Warm featured properties cache */ - async warmFeaturedPropertiesCache( - propertiesData: any, - ): Promise { + async warmFeaturedPropertiesCache(propertiesData: any): Promise { try { await this.cacheService.set( CACHE_KEYS.PROPERTIES_FEATURED, diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts index 470da77f..15eace1b 100644 --- a/src/cache/cache.module.ts +++ b/src/cache/cache.module.ts @@ -10,21 +10,26 @@ import { CacheService } from './cache.service'; import { CacheMonitoringService } from './cache-monitoring.service'; import { CacheWarmingService } from './cache-warming.service'; import { CacheMetricsInterceptor } from './cache-metrics.interceptor'; +import { CacheHeadersInterceptor } from './cache-headers.interceptor'; +import { CacheStatsController } from './cache-stats.controller'; @Global() @Module({ imports: [NestCacheModule.register(REDIS_CONFIG)], + controllers: [CacheStatsController], providers: [ CacheService, CacheMonitoringService, CacheWarmingService, CacheMetricsInterceptor, + CacheHeadersInterceptor, ], exports: [ CacheService, CacheMonitoringService, CacheWarmingService, CacheMetricsInterceptor, + CacheHeadersInterceptor, ], }) export class CacheModuleConfig {} diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index c16e9e19..828b0485 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -167,10 +167,7 @@ export class CacheService { * Invalidate property-related cache */ async invalidatePropertyCache(propertyId?: string): Promise { - const keys = [ - CACHE_KEYS.PROPERTIES_LIST, - CACHE_KEYS.PROPERTIES_FEATURED, - ]; + const keys = [CACHE_KEYS.PROPERTIES_LIST, CACHE_KEYS.PROPERTIES_FEATURED]; if (propertyId) { keys.push(CACHE_KEYS.PROPERTY_BY_ID(propertyId)); } @@ -181,10 +178,7 @@ export class CacheService { * Invalidate dashboard cache */ async invalidateDashboardCache(userId: string): Promise { - const keys = [ - CACHE_KEYS.DASHBOARD_STATS(userId), - CACHE_KEYS.DASHBOARD_ANALYTICS(userId), - ]; + const keys = [CACHE_KEYS.DASHBOARD_STATS(userId), CACHE_KEYS.DASHBOARD_ANALYTICS(userId)]; await this.delMultiple(keys); } @@ -202,9 +196,7 @@ export class CacheService { /** * Warm up cache for featured properties */ - async warmFeaturedPropertiesCache( - factory: () => Promise, - ): Promise { + async warmFeaturedPropertiesCache(factory: () => Promise): Promise { try { const data = await factory(); await this.set( @@ -222,9 +214,7 @@ export class CacheService { /** * Warm up cache for trust score leaderboard */ - async warmTrustScoreLeaderboardCache( - factory: () => Promise, - ): Promise { + async warmTrustScoreLeaderboardCache(factory: () => Promise): Promise { try { const data = await factory(); await this.set( diff --git a/src/documents/documents.controller.ts b/src/documents/documents.controller.ts index 409edccd..4d769aae 100644 --- a/src/documents/documents.controller.ts +++ b/src/documents/documents.controller.ts @@ -39,10 +39,7 @@ export class DocumentsController { } @Get() - findAll( - @CurrentUser() user: AuthUserPayload, - @Query() filter: FilterDocumentsDto, - ) { + findAll(@CurrentUser() user: AuthUserPayload, @Query() filter: FilterDocumentsDto) { return this.documentsService.findAll(user.sub, filter); } diff --git a/src/documents/documents.service.ts b/src/documents/documents.service.ts index 63ed6acb..2b41ba59 100644 --- a/src/documents/documents.service.ts +++ b/src/documents/documents.service.ts @@ -1,8 +1,4 @@ -import { - Injectable, - NotFoundException, - BadRequestException, -} from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import * as crypto from 'crypto'; import * as archiver from 'archiver'; import { Response } from 'express'; @@ -171,20 +167,16 @@ export class DocumentsService { if (!docs.length) throw new NotFoundException('No documents found'); res.setHeader('Content-Type', 'application/zip'); - res.setHeader( - 'Content-Disposition', - `attachment; filename="documents-${Date.now()}.zip"`, - ); + res.setHeader('Content-Disposition', `attachment; filename="documents-${Date.now()}.zip"`); const archive = archiver('zip', { zlib: { level: 6 } }); archive.pipe(res); for (const doc of docs) { // Append file URL as a reference entry (actual file streaming requires storage integration) - archive.append( - `File: ${doc.fileName}\nURL: ${doc.fileUrl}\nType: ${doc.documentType}\n`, - { name: `${doc.id}-${doc.fileName}.txt` }, - ); + archive.append(`File: ${doc.fileName}\nURL: ${doc.fileUrl}\nType: ${doc.documentType}\n`, { + name: `${doc.id}-${doc.fileName}.txt`, + }); } await archive.finalize(); diff --git a/src/documents/dto/document.dto.ts b/src/documents/dto/document.dto.ts index 6d3f5844..93d21071 100644 --- a/src/documents/dto/document.dto.ts +++ b/src/documents/dto/document.dto.ts @@ -1,11 +1,4 @@ -import { - IsString, - IsOptional, - IsArray, - IsDateString, - IsBoolean, - IsIn, -} from 'class-validator'; +import { IsString, IsOptional, IsArray, IsDateString, IsBoolean, IsIn } from 'class-validator'; export const DOCUMENT_TYPE_ENUM = [ 'TITLE_DEED', diff --git a/src/integrations/integrations.controller.ts b/src/integrations/integrations.controller.ts new file mode 100644 index 00000000..d58d79d7 --- /dev/null +++ b/src/integrations/integrations.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { IntegrationsService } from './integrations.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('integrations') +@UseGuards(JwtAuthGuard) +export class IntegrationsController { + constructor(private readonly integrations: IntegrationsService) {} + + @Get('mls/listings') + searchMls( + @Query('location') location?: string, + @Query('minPrice') minPrice?: string, + @Query('maxPrice') maxPrice?: string, + ) { + return this.integrations.searchMlsListings({ + location, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + }); + } + + @Get('mls/listings/:mlsId') + getMlsListing(@Param('mlsId') mlsId: string) { + return this.integrations.getMlsListing(mlsId); + } + + @Post('payments/process') + processPayment(@Body() body: { amount: number; currency: string; token: string }) { + return this.integrations.processPayment(body.amount, body.currency, body.token); + } + + @Post('payments/:transactionId/refund') + refundPayment(@Param('transactionId') transactionId: string) { + return this.integrations.refundPayment(transactionId); + } + + @Post('crm/contacts') + createContact( + @Body() body: { name: string; email: string; phone?: string; type: 'lead' | 'client' }, + ) { + return this.integrations.createCrmContact(body); + } + + @Get('crm/contacts/:id') + getContact(@Param('id') id: string) { + return this.integrations.getCrmContact(id); + } +} diff --git a/src/integrations/integrations.module.ts b/src/integrations/integrations.module.ts new file mode 100644 index 00000000..752439d4 --- /dev/null +++ b/src/integrations/integrations.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IntegrationsService } from './integrations.service'; +import { IntegrationsController } from './integrations.controller'; + +@Module({ + controllers: [IntegrationsController], + providers: [IntegrationsService], + exports: [IntegrationsService], +}) +export class IntegrationsModule {} diff --git a/src/integrations/integrations.service.ts b/src/integrations/integrations.service.ts new file mode 100644 index 00000000..8b87122b --- /dev/null +++ b/src/integrations/integrations.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; + +export interface MlsListing { + mlsId: string; + address: string; + price: number; + bedrooms: number; + bathrooms: number; + sqft: number; + status: string; +} + +export interface PaymentResult { + transactionId: string; + status: 'success' | 'failed' | 'pending'; + amount: number; + currency: string; +} + +export interface CrmContact { + id: string; + name: string; + email: string; + phone?: string; + type: 'lead' | 'client'; +} + +@Injectable() +export class IntegrationsService { + private readonly logger = new Logger(IntegrationsService.name); + + // MLS Integration + async searchMlsListings(query: { + location?: string; + minPrice?: number; + maxPrice?: number; + }): Promise { + this.logger.debug(`MLS search: ${JSON.stringify(query)}`); + // Stub: replace with real MLS API (e.g. RETS/RESO) + return []; + } + + async getMlsListing(mlsId: string): Promise { + this.logger.debug(`MLS get listing: ${mlsId}`); + return null; + } + + // Payment Gateway Integration + async processPayment(amount: number, currency: string, token: string): Promise { + this.logger.debug(`Processing payment: ${amount} ${currency}`); + // Stub: replace with Stripe/PayPal SDK + return { + transactionId: `txn_${Date.now()}`, + status: 'pending', + amount, + currency, + }; + } + + async refundPayment(transactionId: string): Promise { + this.logger.debug(`Refunding: ${transactionId}`); + return { + transactionId, + status: 'pending', + amount: 0, + currency: 'USD', + }; + } + + // CRM Integration + async createCrmContact(contact: Omit): Promise { + this.logger.debug(`CRM create contact: ${contact.email}`); + // Stub: replace with HubSpot/Salesforce SDK + return { ...contact, id: `crm_${Date.now()}` }; + } + + async getCrmContact(id: string): Promise { + this.logger.debug(`CRM get contact: ${id}`); + return null; + } + + async syncCrmContact(userId: string, data: Partial): Promise { + this.logger.debug(`CRM sync user ${userId}`); + } +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 04a9a4f0..cd868c62 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -51,10 +51,12 @@ export class UsersService implements OnModuleInit { const propertiesCount = user.properties.length; const transactionsCount = user.buyerTransactions.length + user.sellerTransactions.length; - + const now = new Date(); const createdAt = new Date(user.createdAt); - const accountAgeDays = Math.floor((now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24)); + const accountAgeDays = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24), + ); return { propertiesCount, @@ -594,5 +596,4 @@ export class UsersService implements OnModuleInit { }, }); } - } diff --git a/test/analytics/analytics.service.spec.ts b/test/analytics/analytics.service.spec.ts new file mode 100644 index 00000000..fb207248 --- /dev/null +++ b/test/analytics/analytics.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsService } from '../../src/analytics/analytics.service'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AnalyticsService], + }).compile(); + service = module.get(AnalyticsService); + }); + + it('returns empty stats with no records', () => { + const stats = service.getStats(); + expect(stats.total).toBe(0); + expect(stats.endpoints).toHaveLength(0); + }); + + it('records requests and returns stats', () => { + service.record({ + endpoint: '/api/properties', + method: 'GET', + statusCode: 200, + responseTime: 50, + }); + service.record({ + endpoint: '/api/properties', + method: 'GET', + statusCode: 200, + responseTime: 100, + }); + service.record({ endpoint: '/api/users', method: 'GET', statusCode: 404, responseTime: 20 }); + + const stats = service.getStats(); + expect(stats.total).toBe(3); + expect(stats.endpoints[0].endpoint).toBe('GET /api/properties'); + expect(stats.endpoints[0].count).toBe(2); + expect(stats.errors).toEqual(expect.arrayContaining([{ statusCode: 404, count: 1 }])); + }); + + it('resets stats', () => { + service.record({ endpoint: '/api/test', method: 'GET', statusCode: 200, responseTime: 10 }); + service.reset(); + expect(service.getStats().total).toBe(0); + }); + + it('tracks average response time', () => { + service.record({ endpoint: '/api/test', method: 'GET', statusCode: 200, responseTime: 100 }); + service.record({ endpoint: '/api/test', method: 'GET', statusCode: 200, responseTime: 200 }); + expect(service.getStats().avgResponseTime).toBe(150); + }); +}); diff --git a/test/api/api.spec.ts b/test/api/api.spec.ts new file mode 100644 index 00000000..e4dca71b --- /dev/null +++ b/test/api/api.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from '../../src/app.controller'; +import { AnalyticsService } from '../../src/analytics/analytics.service'; +import { AnalyticsInterceptor } from '../../src/analytics/analytics.interceptor'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; + +describe('AppController (endpoint tests)', () => { + let controller: AppController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + }).compile(); + controller = module.get(AppController); + }); + + it('GET / returns welcome message', () => { + expect(controller.getHello()).toBe('Welcome to PropChain API'); + }); + + it('GET /health returns OK status', () => { + const result = controller.health(); + expect(result.status).toBe('OK'); + expect(result.timestamp).toBeDefined(); + }); +}); + +describe('AnalyticsInterceptor (integration test)', () => { + let analytics: AnalyticsService; + let interceptor: AnalyticsInterceptor; + + beforeEach(() => { + analytics = new AnalyticsService(); + interceptor = new AnalyticsInterceptor(analytics); + }); + + it('records request on response', (done) => { + const ctx = { + switchToHttp: () => ({ + getRequest: () => ({ path: '/api/test', method: 'GET' }), + getResponse: () => ({ statusCode: 200 }), + }), + } as unknown as ExecutionContext; + const next: CallHandler = { handle: () => of('ok') }; + + interceptor.intercept(ctx, next).subscribe(() => { + const stats = analytics.getStats(); + expect(stats.total).toBe(1); + expect(stats.endpoints[0].endpoint).toBe('GET /api/test'); + done(); + }); + }); +}); + +describe('Performance tests', () => { + it('AnalyticsService handles 1000 records within 50ms', () => { + const service = new AnalyticsService(); + const start = Date.now(); + for (let i = 0; i < 1000; i++) { + service.record({ + endpoint: `/api/route${i % 10}`, + method: 'GET', + statusCode: 200, + responseTime: i, + }); + } + service.getStats(); + expect(Date.now() - start).toBeLessThan(50); + }); + + it('CacheMonitoringService handles 1000 hits within 10ms', () => { + const monitoring = new CacheMonitoringService(); + const start = Date.now(); + for (let i = 0; i < 1000; i++) monitoring.recordHit(); + expect(Date.now() - start).toBeLessThan(10); + expect(monitoring.getMetrics().hits).toBe(1000); + }); +}); + +// Import needed for performance test +import { CacheMonitoringService } from '../../src/cache/cache-monitoring.service'; diff --git a/test/cache/cache-stats.spec.ts b/test/cache/cache-stats.spec.ts new file mode 100644 index 00000000..018f61d2 --- /dev/null +++ b/test/cache/cache-stats.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheMonitoringService } from '../../src/cache/cache-monitoring.service'; +import { CacheStatsController } from '../../src/cache/cache-stats.controller'; +import { CacheService } from '../../src/cache/cache.service'; +import { CacheHeadersInterceptor } from '../../src/cache/cache-headers.interceptor'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../src/auth/guards/roles.guard'; + +const allowAll = { canActivate: () => true }; + +describe('CacheStatsController', () => { + let controller: CacheStatsController; + let monitoring: CacheMonitoringService; + let cacheService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CacheStatsController], + providers: [ + CacheMonitoringService, + { provide: CacheService, useValue: { clear: jest.fn(), getStats: jest.fn() } }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue(allowAll) + .overrideGuard(RolesGuard) + .useValue(allowAll) + .compile(); + + controller = module.get(CacheStatsController); + monitoring = module.get(CacheMonitoringService); + cacheService = module.get(CacheService); + }); + + it('returns cache metrics', () => { + monitoring.recordHit(); + monitoring.recordMiss(); + const stats = controller.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.totalRequests).toBe(2); + }); + + it('clears cache and resets metrics', async () => { + monitoring.recordHit(); + await controller.clearCache(); + expect(cacheService.clear).toHaveBeenCalled(); + expect(controller.getStats().totalRequests).toBe(0); + }); +}); + +describe('CacheHeadersInterceptor', () => { + it('sets cache headers on response', (done) => { + const interceptor = new CacheHeadersInterceptor(); + const mockRes = { setHeader: jest.fn() }; + const ctx = { + switchToHttp: () => ({ getResponse: () => mockRes }), + } as unknown as ExecutionContext; + const next: CallHandler = { handle: () => of('data') }; + + interceptor.intercept(ctx, next).subscribe(() => { + expect(mockRes.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=60'); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'X-Cache-Time', + expect.stringMatching(/\d+ms/), + ); + done(); + }); + }); +}); diff --git a/test/integrations/integrations.service.spec.ts b/test/integrations/integrations.service.spec.ts new file mode 100644 index 00000000..5dd6b801 --- /dev/null +++ b/test/integrations/integrations.service.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IntegrationsService } from '../../src/integrations/integrations.service'; + +describe('IntegrationsService', () => { + let service: IntegrationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [IntegrationsService], + }).compile(); + service = module.get(IntegrationsService); + }); + + describe('MLS', () => { + it('returns empty array for listings search', async () => { + const result = await service.searchMlsListings({ location: 'NYC' }); + expect(Array.isArray(result)).toBe(true); + }); + + it('returns null for unknown listing', async () => { + expect(await service.getMlsListing('unknown')).toBeNull(); + }); + }); + + describe('Payments', () => { + it('processes payment and returns result', async () => { + const result = await service.processPayment(1000, 'USD', 'tok_test'); + expect(result.status).toBe('pending'); + expect(result.amount).toBe(1000); + expect(result.currency).toBe('USD'); + expect(result.transactionId).toBeDefined(); + }); + + it('refunds payment', async () => { + const result = await service.refundPayment('txn_123'); + expect(result.transactionId).toBe('txn_123'); + expect(result.status).toBe('pending'); + }); + }); + + describe('CRM', () => { + it('creates a contact with generated id', async () => { + const contact = await service.createCrmContact({ + name: 'John', + email: 'john@example.com', + type: 'lead', + }); + expect(contact.id).toBeDefined(); + expect(contact.email).toBe('john@example.com'); + }); + + it('returns null for unknown contact', async () => { + expect(await service.getCrmContact('unknown')).toBeNull(); + }); + }); +});