2026-04-15-4

This commit is contained in:
Hamza-Ayed
2026-04-15 19:56:49 +03:00
parent 9cd1ac4c1d
commit 3d61362602
54 changed files with 12659 additions and 436 deletions
+42
View File
@@ -0,0 +1,42 @@
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
import { UsageService } from './usage.service';
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@ApiTags('usage')
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Controller('usage')
export class UsageController {
constructor(private usageService: UsageService) {}
@Get('summary')
@ApiOperation({ summary: 'Get current month usage summary' })
async getSummary(@Req() req: any) {
const tenant = req.tenant;
const summary = await this.usageService.getUsageSummary(tenant.id);
// Limits
const limits = {
FREE: 8000,
PRO: 50000,
ENTERPRISE: 1000000,
};
const limit = limits[tenant.plan] || 8000;
return {
...summary,
limit,
percentage: Math.min(((summary.monthlyUsage / limit) * 100), 100).toFixed(1),
plan: tenant.plan,
};
}
@Get('history')
@ApiOperation({ summary: 'Get daily request volume history' })
async getHistory(@Req() req: any, @Query('days') days: number = 30) {
const tenantId = req.tenant.id;
return this.usageService.getUsageHistory(tenantId, days);
}
}
+43
View File
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('usage_logs')
export class UsageLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
tenantId: string;
@Index()
@Column()
apiKeyId: string;
@Column()
endpoint: string; // e.g., '/maps/style.json', '/geocoding/search'
@Column()
method: string; // 'GET', 'POST', etc.
@Column({ type: 'int', default: 200 })
statusCode: number;
@Column({ type: 'int', default: 0 })
responseTimeMs: number;
@Column({ nullable: true })
userAgent: string;
@Column({ nullable: true })
ipAddress: string;
@Index()
@CreateDateColumn()
createdAt: Date;
}
+74
View File
@@ -0,0 +1,74 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { UsageService } from './usage.service';
import { TenantPlan } from '../auth/entities/tenant.entity';
// Quota Limits per Plan
const QUOTA_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 8000,
[TenantPlan.PRO]: 50000,
[TenantPlan.ENTERPRISE]: 1000000,
};
@Injectable()
export class UsageInterceptor implements NestInterceptor {
private readonly logger = new Logger(UsageInterceptor.name);
constructor(private usageService: UsageService) { }
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const tenant = request.tenant;
const apiKey = request.apiKey; // Set by ApiKeyGuard
// 1. Quota Enforcement (Skip for Management requests / internal)
if (tenant && apiKey && !request.url.includes('/auth/management')) {
this.logger.debug(`📊 Monitoring request for Tenant: ${tenant.id}`);
const plan = tenant.plan || TenantPlan.FREE;
const limit = QUOTA_LIMITS[plan];
const { allowed, used } = await this.usageService.checkQuota(tenant.id, limit);
if (!allowed) {
throw new HttpException({
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Monthly API usage quota exceeded',
used,
limit,
upgrade_url: 'https://map-dashbord.intaleqapp.com/#billing'
}, HttpStatus.TOO_MANY_REQUESTS);
}
}
const startTime = Date.now();
return next.handle().pipe(
tap(() => {
// 2. Async Recording
if (tenant && apiKey) {
const response = context.switchToHttp().getResponse();
const responseTimeMs = Date.now() - startTime;
this.logger.log(`📈 Usage Recorded: ${request.method} ${request.url.split('?')[0]} for Tenant ${tenant.id}`);
this.usageService.recordRequest({
tenantId: tenant.id,
apiKeyId: apiKey.id,
endpoint: request.url.split('?')[0],
method: request.method,
statusCode: response.statusCode,
responseTimeMs,
userAgent: request.headers['user-agent'],
ipAddress: request.ip || request.headers['x-forwarded-for'],
});
}
}),
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsageLog } from './usage.entity';
import { UsageService } from './usage.service';
import { UsageController } from './usage.controller';
@Module({
imports: [
TypeOrmModule.forFeature([UsageLog]),
],
providers: [UsageService],
controllers: [UsageController],
exports: [UsageService],
})
export class UsageModule {}
+115
View File
@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsageLog } from './usage.entity';
import { RedisService } from '../common/redis.service';
@Injectable()
export class UsageService {
private readonly logger = new Logger(UsageService.name);
constructor(
@InjectRepository(UsageLog)
private usageRepository: Repository<UsageLog>,
private redisService: RedisService,
) {}
/**
* Record a request in both PostgreSQL (for history) and Redis (for instant quota)
*/
async recordRequest(data: {
tenantId: string;
apiKeyId: string;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
userAgent?: string;
ipAddress?: string;
}) {
// 1. Log to PostgreSQL (Async)
this.usageRepository.save(data).catch((err) => {
this.logger.error(`Failed to save usage log to DB: ${err.message}`);
});
// 2. Increment Redis Counter for the current month
const monthKey = this.getMonthlyKey(data.tenantId);
try {
await this.redisService.getClient().incr(monthKey);
} catch (err) {
this.logger.error(`Failed to increment Redis usage counter: ${err.message}`);
}
}
/**
* Get current month usage from Redis
*/
async getMonthlyUsage(tenantId: string): Promise<number> {
const key = this.getMonthlyKey(tenantId);
const val = await this.redisService.getClient().get(key);
return val ? parseInt(val, 10) : 0;
}
/**
* Check if a tenant has exceeded their monthly quota
*/
async checkQuota(tenantId: string, limit: number): Promise<{ allowed: boolean; used: number }> {
const used = await this.getMonthlyUsage(tenantId);
return {
allowed: used < limit,
used,
};
}
/**
* Get usage history for charts
*/
async getUsageHistory(tenantId: string, days: number = 30) {
return this.usageRepository
.createQueryBuilder('usage')
.select("DATE_TRUNC('day', usage.createdAt)", 'date')
.addSelect('COUNT(*)', 'count')
.where('usage.tenantId = :tenantId', { tenantId })
.andWhere("usage.createdAt >= NOW() - (:days || ' days')::INTERVAL", { days: days.toString() })
.groupBy("DATE_TRUNC('day', usage.createdAt)")
.orderBy('date', 'ASC')
.getRawMany();
}
/**
* Get real-time summary for the dashboard
*/
async getUsageSummary(tenantId: string) {
const monthlyUsage = await this.getMonthlyUsage(tenantId);
// Get daily stats and performance metrics
const stats = await this.usageRepository
.createQueryBuilder('usage')
.select('COUNT(*)', 'totalToday')
.addSelect('AVG(usage.responseTimeMs)', 'avgLatency')
.addSelect('COUNT(CASE WHEN usage.statusCode >= 200 AND usage.statusCode < 300 THEN 1 END)', 'successCount')
.where('usage.tenantId = :tenantId', { tenantId })
.andWhere('usage.createdAt >= CURRENT_DATE')
.getRawOne();
const totalToday = parseInt(stats.totalToday || '0', 10);
const avgLatency = Math.round(parseFloat(stats.avgLatency || '0'));
const successRate = totalToday > 0
? Math.round((parseInt(stats.successCount || '0', 10) / totalToday) * 100)
: 100;
return {
monthlyUsage,
totalToday,
avgLatency,
successRate,
lastUpdated: new Date()
};
}
private getMonthlyKey(tenantId: string): string {
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
return `usage:${tenantId}:${yearMonth}`;
}
}