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
37 changes: 22 additions & 15 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 } },
Expand Down
19 changes: 17 additions & 2 deletions src/admin/dto/admin.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
24 changes: 24 additions & 0 deletions src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
}
26 changes: 26 additions & 0 deletions src/analytics/analytics.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {

Check warning on line 10 in src/analytics/analytics.interceptor.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
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,
});
}),
);
}
}
12 changes: 12 additions & 0 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
70 changes: 70 additions & 0 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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<RequestRecord, 'timestamp'>): 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<string, { count: number; totalTime: number }>();
const errorMap = new Map<number, number>();
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 = [];
}
}
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,6 +23,7 @@ import { AdminModule } from './admin/admin.module';
envFilePath: ['.env.local', '.env'],
}),
CacheModuleConfig,
AnalyticsModule,
PrismaModule,
VersioningModule,
ApiDocumentationModule,
Expand All @@ -32,6 +35,7 @@ import { AdminModule } from './admin/admin.module';
PropertiesModule,
AdminModule,
DocumentsModule,
IntegrationsModule,
],
controllers: [AppController],
})
Expand Down
15 changes: 4 additions & 11 deletions src/auth/controllers/rate-limit-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ export class RateLimitAdminController {
},
},
})
async getUserRateLimitStatus(
@Param('userId') userId: string,
): Promise<any> {
async getUserRateLimitStatus(@Param('userId') userId: string): Promise<any> {
return this.rateLimitService.getUserRateLimitStats(userId);
}

Expand All @@ -55,9 +53,7 @@ export class RateLimitAdminController {
status: 200,
description: 'Endpoint rate limit status retrieved successfully',
})
async getEndpointRateLimitStatus(
@Param('endpoint') endpoint: string,
): Promise<RateLimitStatus> {
async getEndpointRateLimitStatus(@Param('endpoint') endpoint: string): Promise<RateLimitStatus> {
return this.rateLimitService.checkEndpointRateLimit(endpoint);
}

Expand Down Expand Up @@ -100,9 +96,7 @@ export class RateLimitAdminController {
status: 204,
description: 'Endpoint rate limit reset successfully',
})
async resetEndpointRateLimit(
@Param('endpoint') endpoint: string,
): Promise<void> {
async resetEndpointRateLimit(@Param('endpoint') endpoint: string): Promise<void> {
return this.rateLimitService.resetEndpointRateLimit(endpoint);
}

Expand All @@ -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,
Expand Down
5 changes: 1 addition & 4 deletions src/auth/decorators/rate-limit.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
26 changes: 10 additions & 16 deletions src/auth/examples/rate-limit.examples.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.",
Expand All @@ -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
Expand Down
Loading
Loading