From fc0ed636fc6addf5e34953125d4c9d536abddb06 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:03:16 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(api-gateway):=20add=20Swagger,?= =?UTF-8?q?=20admin=20endpoints,=20and=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Swagger/OpenAPI documentation at /docs endpoint - Add admin module for system-wide API key management - Add scheduler for monthly credit reset and usage cleanup - Add Docker Compose entry for Mac Mini deployment - Document all endpoints with descriptions and examples --- docker-compose.macmini.yml | 36 +++ services/mana-api-gateway/.env.example | 3 + services/mana-api-gateway/CLAUDE.md | 13 + services/mana-api-gateway/package.json | 2 + .../src/admin/admin.controller.ts | 118 ++++++++ .../src/admin/admin.module.ts | 10 + .../src/admin/admin.service.ts | 264 ++++++++++++++++++ .../src/admin/dto/admin-update-key.dto.ts | 100 +++++++ .../src/api-keys/api-keys.controller.ts | 63 +++++ .../src/api-keys/dto/create-api-key.dto.ts | 35 ++- .../src/api-keys/dto/update-api-key.dto.ts | 27 ++ services/mana-api-gateway/src/app.module.ts | 4 + .../src/config/configuration.ts | 5 + .../src/guards/admin.guard.ts | 37 +++ .../src/health/health.controller.ts | 18 ++ services/mana-api-gateway/src/main.ts | 42 +++ .../src/metrics/metrics.controller.ts | 11 + .../src/proxy/proxy.controller.ts | 172 ++++++++++++ .../src/scheduler/scheduler.module.ts | 9 + .../src/scheduler/scheduler.service.ts | 72 +++++ .../src/usage/dto/usage-query.dto.ts | 19 ++ 21 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 services/mana-api-gateway/src/admin/admin.controller.ts create mode 100644 services/mana-api-gateway/src/admin/admin.module.ts create mode 100644 services/mana-api-gateway/src/admin/admin.service.ts create mode 100644 services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts create mode 100644 services/mana-api-gateway/src/guards/admin.guard.ts create mode 100644 services/mana-api-gateway/src/scheduler/scheduler.module.ts create mode 100644 services/mana-api-gateway/src/scheduler/scheduler.service.ts diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index a4d1d1f1d..a6b432b00 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -103,6 +103,42 @@ services: retries: 3 start_period: 40s + # ============================================ + # API Gateway (Monetization) + # ============================================ + + api-gateway: + image: ghcr.io/memo-2023/api-gateway:latest + container_name: mana-api-gateway + restart: always + depends_on: + mana-core-auth: + condition: service_healthy + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3030 + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/manacore + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} + MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + SEARCH_SERVICE_URL: http://mana-search:3021 + STT_SERVICE_URL: http://mana-stt:3020 + TTS_SERVICE_URL: http://mana-tts:3022 + CORS_ORIGINS: https://api.mana.how,https://mana.how + ports: + - "3030:3030" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3030/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # ManaCore Dashboard # ============================================ diff --git a/services/mana-api-gateway/.env.example b/services/mana-api-gateway/.env.example index 7edf19f23..68d98c052 100644 --- a/services/mana-api-gateway/.env.example +++ b/services/mana-api-gateway/.env.example @@ -32,3 +32,6 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:5173 # Development Auth Bypass DEV_BYPASS_AUTH=true DEV_USER_ID=00000000-0000-0000-0000-000000000000 + +# Admin Access (comma-separated user IDs) +ADMIN_USER_IDS= diff --git a/services/mana-api-gateway/CLAUDE.md b/services/mana-api-gateway/CLAUDE.md index 8f58e0c4d..39898d238 100644 --- a/services/mana-api-gateway/CLAUDE.md +++ b/services/mana-api-gateway/CLAUDE.md @@ -85,12 +85,24 @@ pnpm start | GET | `/api-keys/:id/usage` | Get usage statistics | | GET | `/api-keys/:id/usage/summary` | Get usage summary | +### Admin API (with JWT + Admin Role) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/api-keys` | List all API keys (paginated) | +| GET | `/admin/api-keys/:id` | Get any API key details | +| PATCH | `/admin/api-keys/:id` | Update any key (tier, credits, limits) | +| DELETE | `/admin/api-keys/:id` | Delete any API key | +| GET | `/admin/usage/summary` | System-wide usage stats | +| GET | `/admin/usage/top-users` | Top users by usage | + ### System | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/health` | Health check | | GET | `/metrics` | Prometheus metrics | +| GET | `/docs` | Swagger/OpenAPI documentation | ## Pricing Tiers @@ -173,6 +185,7 @@ curl http://localhost:3030/api-keys/{id}/usage \ | `STT_SERVICE_URL` | http://localhost:3020 | mana-stt URL | | `TTS_SERVICE_URL` | http://localhost:3022 | mana-tts URL | | `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL | +| `ADMIN_USER_IDS` | - | Comma-separated admin user IDs | ## Development Commands diff --git a/services/mana-api-gateway/package.json b/services/mana-api-gateway/package.json index 903f275e5..94ee43ee7 100644 --- a/services/mana-api-gateway/package.json +++ b/services/mana-api-gateway/package.json @@ -27,6 +27,8 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.17", "@nestjs/platform-express": "^10.4.17", + "@nestjs/schedule": "^4.1.2", + "@nestjs/swagger": "^11.2.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "drizzle-orm": "^0.38.4", diff --git a/services/mana-api-gateway/src/admin/admin.controller.ts b/services/mana-api-gateway/src/admin/admin.controller.ts new file mode 100644 index 000000000..3dd871687 --- /dev/null +++ b/services/mana-api-gateway/src/admin/admin.controller.ts @@ -0,0 +1,118 @@ +import { Controller, Get, Patch, Delete, Param, Query, Body, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { AdminGuard } from '../guards/admin.guard'; +import { AdminService } from './admin.service'; +import { AdminUpdateKeyDto } from './dto/admin-update-key.dto'; + +@ApiTags('Admin') +@ApiBearerAuth('jwt') +@Controller('admin') +@UseGuards(JwtAuthGuard, AdminGuard) +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @Get('api-keys') + @ApiOperation({ + summary: 'List all API keys', + description: 'Returns all API keys in the system. Requires admin role.', + }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'userId', required: false, type: String }) + @ApiQuery({ name: 'tier', required: false, enum: ['free', 'pro', 'enterprise'] }) + @ApiQuery({ name: 'active', required: false, type: Boolean }) + @ApiResponse({ status: 200, description: 'List of all API keys' }) + @ApiResponse({ status: 403, description: 'Forbidden - admin role required' }) + async listAllKeys( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('userId') userId?: string, + @Query('tier') tier?: string, + @Query('active') active?: string + ) { + const pageNum = parseInt(page || '1', 10); + const limitNum = parseInt(limit || '50', 10); + const isActive = active === undefined ? undefined : active === 'true'; + + const result = await this.adminService.listAllKeys({ + page: pageNum, + limit: limitNum, + userId, + tier, + active: isActive, + }); + + return result; + } + + @Get('api-keys/:id') + @ApiOperation({ + summary: 'Get API key details (admin)', + description: 'Returns full details of any API key including usage stats.', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key details with usage' }) + @ApiResponse({ status: 404, description: 'API key not found' }) + async getKey(@Param('id') id: string) { + return this.adminService.getKeyDetails(id); + } + + @Patch('api-keys/:id') + @ApiOperation({ + summary: 'Update API key (admin)', + description: 'Update any API key including tier, credits, rate limits.', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key updated' }) + @ApiResponse({ status: 404, description: 'API key not found' }) + async updateKey(@Param('id') id: string, @Body() dto: AdminUpdateKeyDto) { + return this.adminService.updateKey(id, dto); + } + + @Delete('api-keys/:id') + @ApiOperation({ + summary: 'Delete API key (admin)', + description: 'Permanently delete any API key.', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key deleted' }) + @ApiResponse({ status: 404, description: 'API key not found' }) + async deleteKey(@Param('id') id: string) { + await this.adminService.deleteKey(id); + return { message: 'API key deleted successfully' }; + } + + @Get('usage/summary') + @ApiOperation({ + summary: 'Get system-wide usage summary', + description: 'Returns aggregated usage stats for all API keys.', + }) + @ApiQuery({ name: 'days', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'System usage summary' }) + async getSystemUsage(@Query('days') days?: string) { + const daysNum = parseInt(days || '30', 10); + return this.adminService.getSystemUsage(daysNum); + } + + @Get('usage/top-users') + @ApiOperation({ + summary: 'Get top users by usage', + description: 'Returns users with highest API usage.', + }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'days', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Top users by usage' }) + async getTopUsers(@Query('limit') limit?: string, @Query('days') days?: string) { + const limitNum = parseInt(limit || '10', 10); + const daysNum = parseInt(days || '30', 10); + return this.adminService.getTopUsers(limitNum, daysNum); + } +} diff --git a/services/mana-api-gateway/src/admin/admin.module.ts b/services/mana-api-gateway/src/admin/admin.module.ts new file mode 100644 index 000000000..3895eb445 --- /dev/null +++ b/services/mana-api-gateway/src/admin/admin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/services/mana-api-gateway/src/admin/admin.service.ts b/services/mana-api-gateway/src/admin/admin.service.ts new file mode 100644 index 000000000..fd9c6a92c --- /dev/null +++ b/services/mana-api-gateway/src/admin/admin.service.ts @@ -0,0 +1,264 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, sql, desc, and, gte } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { apiKeys, apiUsage, apiUsageDaily } from '../db/schema'; +import { PRICING_TIERS, PricingTier } from '../config/pricing'; +import { AdminUpdateKeyDto } from './dto/admin-update-key.dto'; + +interface ListKeysOptions { + page: number; + limit: number; + userId?: string; + tier?: string; + active?: boolean; +} + +@Injectable() +export class AdminService { + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: ReturnType + ) {} + + async listAllKeys(options: ListKeysOptions) { + const { page, limit, userId, tier, active } = options; + const offset = (page - 1) * limit; + + // Build conditions + const conditions = []; + if (userId) { + conditions.push(eq(apiKeys.userId, userId)); + } + if (tier) { + conditions.push(eq(apiKeys.tier, tier)); + } + if (active !== undefined) { + conditions.push(eq(apiKeys.active, active)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [keys, countResult] = await Promise.all([ + this.db + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + userId: apiKeys.userId, + organizationId: apiKeys.organizationId, + tier: apiKeys.tier, + rateLimit: apiKeys.rateLimit, + monthlyCredits: apiKeys.monthlyCredits, + creditsUsed: apiKeys.creditsUsed, + active: apiKeys.active, + lastUsedAt: apiKeys.lastUsedAt, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where(whereClause) + .orderBy(desc(apiKeys.createdAt)) + .limit(limit) + .offset(offset), + this.db + .select({ count: sql`count(*)` }) + .from(apiKeys) + .where(whereClause), + ]); + + const total = Number(countResult[0]?.count || 0); + + return { + apiKeys: keys, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async getKeyDetails(id: string) { + const key = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1); + + if (!key.length) { + throw new NotFoundException('API key not found'); + } + + // Get recent usage + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentUsage = await this.db + .select({ + endpoint: apiUsageDaily.endpoint, + requestCount: sql`sum(${apiUsageDaily.requestCount})`, + creditsUsed: sql`sum(${apiUsageDaily.creditsUsed})`, + }) + .from(apiUsageDaily) + .where( + and( + eq(apiUsageDaily.apiKeyId, id), + gte(apiUsageDaily.date, thirtyDaysAgo.toISOString().split('T')[0]) + ) + ) + .groupBy(apiUsageDaily.endpoint); + + return { + apiKey: { + ...key[0], + keyHash: undefined, // Don't expose hash + }, + usage: { + last30Days: recentUsage, + }, + }; + } + + async updateKey(id: string, dto: AdminUpdateKeyDto) { + // Verify key exists + const existing = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1); + + if (!existing.length) { + throw new NotFoundException('API key not found'); + } + + // Build update object + const updates: Record = { + updatedAt: new Date(), + }; + + if (dto.name !== undefined) updates.name = dto.name; + if (dto.description !== undefined) updates.description = dto.description; + if (dto.active !== undefined) updates.active = dto.active; + if (dto.expiresAt !== undefined) updates.expiresAt = new Date(dto.expiresAt); + if (dto.rateLimit !== undefined) updates.rateLimit = dto.rateLimit; + if (dto.monthlyCredits !== undefined) updates.monthlyCredits = dto.monthlyCredits; + + if (dto.allowedEndpoints !== undefined) { + updates.allowedEndpoints = JSON.stringify(dto.allowedEndpoints); + } + + if (dto.allowedIps !== undefined) { + updates.allowedIps = JSON.stringify(dto.allowedIps); + } + + if (dto.resetCredits) { + updates.creditsUsed = 0; + } + + // If tier is changed, apply tier defaults + if (dto.tier !== undefined) { + const tierConfig = PRICING_TIERS[dto.tier as PricingTier]; + updates.tier = dto.tier; + // Only apply tier defaults if not explicitly set + if (dto.rateLimit === undefined) updates.rateLimit = tierConfig.rateLimit; + if (dto.monthlyCredits === undefined) updates.monthlyCredits = tierConfig.monthlyCredits; + } + + const [updated] = await this.db + .update(apiKeys) + .set(updates) + .where(eq(apiKeys.id, id)) + .returning(); + + return { + apiKey: { + ...updated, + keyHash: undefined, + }, + }; + } + + async deleteKey(id: string) { + const existing = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1); + + if (!existing.length) { + throw new NotFoundException('API key not found'); + } + + // Delete usage data first (foreign key constraint) + await this.db.delete(apiUsageDaily).where(eq(apiUsageDaily.apiKeyId, id)); + await this.db.delete(apiUsage).where(eq(apiUsage.apiKeyId, id)); + + // Delete the key + await this.db.delete(apiKeys).where(eq(apiKeys.id, id)); + } + + async getSystemUsage(days: number) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const dailyStats = await this.db + .select({ + date: apiUsageDaily.date, + requestCount: sql`sum(${apiUsageDaily.requestCount})`, + creditsUsed: sql`sum(${apiUsageDaily.creditsUsed})`, + errorCount: sql`sum(${apiUsageDaily.errorCount})`, + }) + .from(apiUsageDaily) + .where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0])) + .groupBy(apiUsageDaily.date) + .orderBy(apiUsageDaily.date); + + const endpointStats = await this.db + .select({ + endpoint: apiUsageDaily.endpoint, + requestCount: sql`sum(${apiUsageDaily.requestCount})`, + creditsUsed: sql`sum(${apiUsageDaily.creditsUsed})`, + }) + .from(apiUsageDaily) + .where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0])) + .groupBy(apiUsageDaily.endpoint); + + const tierStats = await this.db + .select({ + tier: apiKeys.tier, + keyCount: sql`count(*)`, + activeCount: sql`sum(case when ${apiKeys.active} then 1 else 0 end)`, + }) + .from(apiKeys) + .groupBy(apiKeys.tier); + + return { + period: { + start: startDate.toISOString().split('T')[0], + end: new Date().toISOString().split('T')[0], + days, + }, + daily: dailyStats, + byEndpoint: endpointStats, + byTier: tierStats, + }; + } + + async getTopUsers(limit: number, days: number) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const topUsers = await this.db + .select({ + apiKeyId: apiUsageDaily.apiKeyId, + keyName: apiKeys.name, + userId: apiKeys.userId, + tier: apiKeys.tier, + requestCount: sql`sum(${apiUsageDaily.requestCount})`, + creditsUsed: sql`sum(${apiUsageDaily.creditsUsed})`, + }) + .from(apiUsageDaily) + .innerJoin(apiKeys, eq(apiUsageDaily.apiKeyId, apiKeys.id)) + .where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0])) + .groupBy(apiUsageDaily.apiKeyId, apiKeys.name, apiKeys.userId, apiKeys.tier) + .orderBy(desc(sql`sum(${apiUsageDaily.requestCount})`)) + .limit(limit); + + return { + period: { + start: startDate.toISOString().split('T')[0], + end: new Date().toISOString().split('T')[0], + days, + }, + topUsers, + }; + } +} diff --git a/services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts b/services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts new file mode 100644 index 000000000..77110049f --- /dev/null +++ b/services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts @@ -0,0 +1,100 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsArray, + IsDateString, + IsInt, + IsEnum, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class AdminUpdateKeyDto { + @ApiPropertyOptional({ + description: 'Update the display name', + example: 'Updated API Key Name', + }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + description: 'Update the description', + example: 'Updated description', + }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + description: 'Change the pricing tier', + enum: ['free', 'pro', 'enterprise'], + }) + @IsString() + @IsOptional() + @IsEnum(['free', 'pro', 'enterprise']) + tier?: 'free' | 'pro' | 'enterprise'; + + @ApiPropertyOptional({ + description: 'Custom rate limit (requests per minute)', + example: 100, + }) + @IsInt() + @IsOptional() + @Min(1) + rateLimit?: number; + + @ApiPropertyOptional({ + description: 'Custom monthly credits limit', + example: 5000, + }) + @IsInt() + @IsOptional() + @Min(0) + monthlyCredits?: number; + + @ApiPropertyOptional({ + description: 'Reset credits used to 0', + example: true, + }) + @IsBoolean() + @IsOptional() + resetCredits?: boolean; + + @ApiPropertyOptional({ + description: 'Update allowed endpoints', + example: ['search', 'stt', 'tts'], + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedEndpoints?: string[]; + + @ApiPropertyOptional({ + description: 'Update IP whitelist', + example: ['192.168.1.0/24'], + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedIps?: string[]; + + @ApiPropertyOptional({ + description: 'Enable or disable the API key', + example: true, + }) + @IsBoolean() + @IsOptional() + active?: boolean; + + @ApiPropertyOptional({ + description: 'Update expiration date (ISO 8601)', + example: '2025-12-31T23:59:59Z', + }) + @IsDateString() + @IsOptional() + expiresAt?: string; +} diff --git a/services/mana-api-gateway/src/api-keys/api-keys.controller.ts b/services/mana-api-gateway/src/api-keys/api-keys.controller.ts index d922d19d0..57cec3a88 100644 --- a/services/mana-api-gateway/src/api-keys/api-keys.controller.ts +++ b/services/mana-api-gateway/src/api-keys/api-keys.controller.ts @@ -9,11 +9,21 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { ApiKeysService } from './api-keys.service'; import { CreateApiKeyDto, UpdateApiKeyDto } from './dto'; import { UsageService } from '../usage/usage.service'; +@ApiTags('API Keys') +@ApiBearerAuth('jwt') @Controller('api-keys') @UseGuards(JwtAuthGuard) export class ApiKeysController { @@ -23,6 +33,12 @@ export class ApiKeysController { ) {} @Post() + @ApiOperation({ + summary: 'Create API key', + description: 'Creates a new API key. The full key is only returned once - save it securely.', + }) + @ApiResponse({ status: 201, description: 'API key created successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing JWT' }) async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) { const result = await this.apiKeyService.create(user.userId, dto); return { @@ -33,18 +49,37 @@ export class ApiKeysController { } @Get() + @ApiOperation({ + summary: 'List API keys', + description: 'Returns all API keys for the authenticated user (without full key values)', + }) + @ApiResponse({ status: 200, description: 'List of API keys' }) async list(@CurrentUser() user: CurrentUserData) { const keys = await this.apiKeyService.listByUser(user.userId); return { apiKeys: keys }; } @Get(':id') + @ApiOperation({ + summary: 'Get API key details', + description: 'Returns details for a specific API key', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key details' }) + @ApiResponse({ status: 404, description: 'API key not found' }) async get(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { const key = await this.apiKeyService.getByIdAndUser(id, user.userId); return { apiKey: key }; } @Patch(':id') + @ApiOperation({ + summary: 'Update API key', + description: 'Updates name, description, allowed endpoints, IP whitelist, or active status', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key updated successfully' }) + @ApiResponse({ status: 404, description: 'API key not found' }) async update( @CurrentUser() user: CurrentUserData, @Param('id') id: string, @@ -55,12 +90,27 @@ export class ApiKeysController { } @Delete(':id') + @ApiOperation({ + summary: 'Delete API key', + description: 'Permanently deletes an API key. This action cannot be undone.', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'API key deleted successfully' }) + @ApiResponse({ status: 404, description: 'API key not found' }) async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { await this.apiKeyService.delete(id, user.userId); return { message: 'API key deleted successfully' }; } @Post(':id/regenerate') + @ApiOperation({ + summary: 'Regenerate API key', + description: + 'Generates a new key value for an existing API key. The old key immediately stops working.', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'New key generated successfully' }) + @ApiResponse({ status: 404, description: 'API key not found' }) async regenerate(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { const result = await this.apiKeyService.regenerate(id, user.userId); return { @@ -71,6 +121,13 @@ export class ApiKeysController { } @Get(':id/usage') + @ApiOperation({ + summary: 'Get daily usage', + description: 'Returns daily usage statistics for an API key', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiQuery({ name: 'days', required: false, description: 'Number of days (default: 30)' }) + @ApiResponse({ status: 200, description: 'Daily usage statistics' }) async getUsage( @CurrentUser() user: CurrentUserData, @Param('id') id: string, @@ -86,6 +143,12 @@ export class ApiKeysController { } @Get(':id/usage/summary') + @ApiOperation({ + summary: 'Get usage summary', + description: 'Returns aggregated usage summary for an API key', + }) + @ApiParam({ name: 'id', description: 'API key ID (UUID)' }) + @ApiResponse({ status: 200, description: 'Usage summary' }) async getUsageSummary(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { // Verify ownership await this.apiKeyService.getByIdAndUser(id, user.userId); diff --git a/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts b/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts index a6ce57d06..d58eae891 100644 --- a/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts +++ b/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts @@ -1,33 +1,66 @@ -import { IsString, IsOptional, IsEnum, IsArray, IsDateString } from 'class-validator'; +import { IsString, IsOptional, IsEnum, IsArray, IsDateString, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PricingTier } from '../../config/pricing'; export class CreateApiKeyDto { + @ApiProperty({ + description: 'Display name for the API key', + example: 'Production API Key', + }) @IsString() name: string; + @ApiPropertyOptional({ + description: 'Optional description for the API key', + example: 'Used for production web application', + }) @IsString() @IsOptional() description?: string; + @ApiPropertyOptional({ + description: 'Pricing tier (determines rate limits and credits)', + enum: ['free', 'pro', 'enterprise'], + default: 'free', + }) @IsString() @IsOptional() @IsEnum(['free', 'pro', 'enterprise']) tier?: PricingTier; + @ApiPropertyOptional({ + description: 'List of allowed endpoints (null = all endpoints allowed)', + example: ['search', 'tts'], + type: [String], + }) @IsArray() @IsString({ each: true }) @IsOptional() allowedEndpoints?: string[]; + @ApiPropertyOptional({ + description: 'IP whitelist for this key (null = all IPs allowed)', + example: ['192.168.1.0/24', '10.0.0.1'], + type: [String], + }) @IsArray() @IsString({ each: true }) @IsOptional() allowedIps?: string[]; + @ApiPropertyOptional({ + description: 'Expiration date for the API key (ISO 8601)', + example: '2025-12-31T23:59:59Z', + }) @IsDateString() @IsOptional() expiresAt?: string; + @ApiPropertyOptional({ + description: 'Create a test key (sk_test_ prefix) instead of live key', + default: false, + }) + @IsBoolean() @IsOptional() isTest?: boolean; } diff --git a/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts b/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts index 2ae7d11d0..69836a6d1 100644 --- a/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts +++ b/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts @@ -1,28 +1,55 @@ import { IsString, IsOptional, IsBoolean, IsArray, IsDateString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; export class UpdateApiKeyDto { + @ApiPropertyOptional({ + description: 'Update the display name', + example: 'Updated API Key Name', + }) @IsString() @IsOptional() name?: string; + @ApiPropertyOptional({ + description: 'Update the description', + example: 'Updated description for this key', + }) @IsString() @IsOptional() description?: string; + @ApiPropertyOptional({ + description: 'Update allowed endpoints', + example: ['search', 'stt', 'tts'], + type: [String], + }) @IsArray() @IsString({ each: true }) @IsOptional() allowedEndpoints?: string[]; + @ApiPropertyOptional({ + description: 'Update IP whitelist', + example: ['192.168.1.0/24'], + type: [String], + }) @IsArray() @IsString({ each: true }) @IsOptional() allowedIps?: string[]; + @ApiPropertyOptional({ + description: 'Enable or disable the API key', + example: true, + }) @IsBoolean() @IsOptional() active?: boolean; + @ApiPropertyOptional({ + description: 'Update expiration date (ISO 8601)', + example: '2025-12-31T23:59:59Z', + }) @IsDateString() @IsOptional() expiresAt?: string; diff --git a/services/mana-api-gateway/src/app.module.ts b/services/mana-api-gateway/src/app.module.ts index 8d32c9fb9..8abdf7765 100644 --- a/services/mana-api-gateway/src/app.module.ts +++ b/services/mana-api-gateway/src/app.module.ts @@ -8,6 +8,8 @@ import { UsageModule } from './usage/usage.module'; import { ProxyModule } from './proxy/proxy.module'; import { CreditsModule } from './credits/credits.module'; import { MetricsModule } from './metrics/metrics.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -22,6 +24,8 @@ import { MetricsModule } from './metrics/metrics.module'; ProxyModule, CreditsModule, MetricsModule, + SchedulerModule, + AdminModule, ], }) export class AppModule {} diff --git a/services/mana-api-gateway/src/config/configuration.ts b/services/mana-api-gateway/src/config/configuration.ts index 83bffa5b9..4cb12dbb7 100644 --- a/services/mana-api-gateway/src/config/configuration.ts +++ b/services/mana-api-gateway/src/config/configuration.ts @@ -39,4 +39,9 @@ export default () => ({ rateLimit: parseInt(process.env.DEFAULT_RATE_LIMIT || '10', 10), monthlyCredits: parseInt(process.env.DEFAULT_MONTHLY_CREDITS || '100', 10), }, + + admin: { + // Comma-separated list of user IDs that have admin access + userIds: process.env.ADMIN_USER_IDS || '', + }, }); diff --git a/services/mana-api-gateway/src/guards/admin.guard.ts b/services/mana-api-gateway/src/guards/admin.guard.ts new file mode 100644 index 000000000..f5768e6a0 --- /dev/null +++ b/services/mana-api-gateway/src/guards/admin.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly adminUserIds: string[]; + + constructor(private readonly configService: ConfigService) { + // Admin user IDs from environment variable (comma-separated) + const adminIds = this.configService.get('admin.userIds') || ''; + this.adminUserIds = adminIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.userId) { + throw new ForbiddenException('User not authenticated'); + } + + // Check if user has admin role + if (user.role === 'admin') { + return true; + } + + // Check if user ID is in the admin list + if (this.adminUserIds.includes(user.userId)) { + return true; + } + + throw new ForbiddenException('Admin access required'); + } +} diff --git a/services/mana-api-gateway/src/health/health.controller.ts b/services/mana-api-gateway/src/health/health.controller.ts index 064010928..2cde48aac 100644 --- a/services/mana-api-gateway/src/health/health.controller.ts +++ b/services/mana-api-gateway/src/health/health.controller.ts @@ -1,8 +1,26 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +@ApiTags('System') @Controller('health') export class HealthController { @Get() + @ApiOperation({ + summary: 'Health check', + description: 'Returns service health status. No authentication required.', + }) + @ApiResponse({ + status: 200, + description: 'Service is healthy', + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + service: { type: 'string', example: 'api-gateway' }, + timestamp: { type: 'string', example: '2025-01-29T10:30:00.000Z' }, + }, + }, + }) check() { return { status: 'ok', diff --git a/services/mana-api-gateway/src/main.ts b/services/mana-api-gateway/src/main.ts index 0516e5a4a..062254cac 100644 --- a/services/mana-api-gateway/src/main.ts +++ b/services/mana-api-gateway/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @@ -29,8 +30,49 @@ async function bootstrap() { // Global exception filter app.useGlobalFilters(new HttpExceptionFilter()); + // Swagger/OpenAPI documentation + const config = new DocumentBuilder() + .setTitle('ManaCore API Gateway') + .setDescription( + 'API Gateway for ManaCore services (Search, STT, TTS). ' + + 'Use X-API-Key header for public endpoints (/v1/*) and Bearer JWT for management endpoints (/api-keys/*).' + ) + .setVersion('1.0') + .addApiKey( + { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + description: 'API Key for accessing public endpoints', + }, + 'api-key' + ) + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT token from mana-core-auth for management endpoints', + }, + 'jwt' + ) + .addTag('Search', 'Web search and content extraction') + .addTag('STT', 'Speech-to-Text transcription') + .addTag('TTS', 'Text-to-Speech synthesis') + .addTag('API Keys', 'API key management (requires JWT authentication)') + .addTag('System', 'Health checks and metrics') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document, { + swaggerOptions: { + persistAuthorization: true, + }, + }); + await app.listen(port); console.log(`API Gateway running on port ${port}`); + console.log(`Swagger docs available at http://localhost:${port}/docs`); } bootstrap(); diff --git a/services/mana-api-gateway/src/metrics/metrics.controller.ts b/services/mana-api-gateway/src/metrics/metrics.controller.ts index d2e9c04ba..dcb015220 100644 --- a/services/mana-api-gateway/src/metrics/metrics.controller.ts +++ b/services/mana-api-gateway/src/metrics/metrics.controller.ts @@ -1,12 +1,23 @@ import { Controller, Get, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiProduces } from '@nestjs/swagger'; import { Response } from 'express'; import { MetricsService } from './metrics.service'; +@ApiTags('System') @Controller('metrics') export class MetricsController { constructor(private readonly metricsService: MetricsService) {} @Get() + @ApiOperation({ + summary: 'Prometheus metrics', + description: 'Returns Prometheus-formatted metrics for monitoring. No authentication required.', + }) + @ApiProduces('text/plain') + @ApiResponse({ + status: 200, + description: 'Prometheus metrics in text format', + }) async getMetrics(@Res() res: Response) { const metrics = await this.metricsService.getMetrics(); res.setHeader('Content-Type', this.metricsService.getContentType()); diff --git a/services/mana-api-gateway/src/proxy/proxy.controller.ts b/services/mana-api-gateway/src/proxy/proxy.controller.ts index 4121d03c3..e3bb68ea8 100644 --- a/services/mana-api-gateway/src/proxy/proxy.controller.ts +++ b/services/mana-api-gateway/src/proxy/proxy.controller.ts @@ -9,6 +9,15 @@ import { Res, Query, } from '@nestjs/common'; +import { + ApiTags, + ApiSecurity, + ApiOperation, + ApiResponse, + ApiConsumes, + ApiBody, + ApiHeader, +} from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import { ApiKeyGuard } from '../guards/api-key.guard'; @@ -26,6 +35,12 @@ import { import { SttProxyService, TranscribeRequestDto } from './services/stt-proxy.service'; import { TtsProxyService, SynthesizeRequestDto } from './services/tts-proxy.service'; +@ApiSecurity('api-key') +@ApiHeader({ + name: 'X-API-Key', + description: 'Your API key (sk_live_xxx or sk_test_xxx)', + required: true, +}) @Controller('v1') @UseGuards(ApiKeyGuard, RateLimitGuard, CreditsGuard) @UseInterceptors(UsageTrackingInterceptor) @@ -39,21 +54,106 @@ export class ProxyController { // === SEARCH === @Post('search') + @ApiTags('Search') + @ApiOperation({ + summary: 'Web search', + description: 'Search the web using multiple search engines. Costs 1 credit per request.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', example: 'quantum computing' }, + options: { + type: 'object', + properties: { + categories: { + type: 'array', + items: { type: 'string' }, + example: ['general', 'science'], + }, + engines: { type: 'array', items: { type: 'string' }, example: ['google', 'bing'] }, + language: { type: 'string', example: 'en' }, + limit: { type: 'number', example: 10 }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Search results' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + @ApiResponse({ status: 402, description: 'Insufficient credits' }) async search(@Body() body: SearchRequestDto, @ApiKeyParam() apiKey: ApiKeyData) { return this.searchProxy.search(body); } @Get('search/engines') + @ApiTags('Search') + @ApiOperation({ + summary: 'Get available search engines', + description: 'Returns a list of available search engines and categories. Free endpoint.', + }) + @ApiResponse({ status: 200, description: 'List of search engines' }) async getEngines() { return this.searchProxy.getEngines(); } @Post('extract') + @ApiTags('Search') + @ApiOperation({ + summary: 'Extract content from URL', + description: 'Extracts main content from a webpage. Costs 1 credit per request.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', example: 'https://example.com/article' }, + options: { + type: 'object', + properties: { + includeMarkdown: { type: 'boolean', example: true }, + maxLength: { type: 'number', example: 5000 }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Extracted content' }) async extract(@Body() body: ExtractRequestDto) { return this.searchProxy.extract(body); } @Post('extract/bulk') + @ApiTags('Search') + @ApiOperation({ + summary: 'Bulk extract content', + description: 'Extracts content from multiple URLs (max 20). Costs 1 credit per URL.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['urls'], + properties: { + urls: { + type: 'array', + items: { type: 'string' }, + example: ['https://example.com/article1', 'https://example.com/article2'], + }, + options: { + type: 'object', + properties: { + includeMarkdown: { type: 'boolean', example: true }, + maxLength: { type: 'number', example: 5000 }, + }, + }, + concurrency: { type: 'number', example: 5 }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Extracted content from all URLs' }) async bulkExtract(@Body() body: BulkExtractRequestDto) { return this.searchProxy.bulkExtract(body); } @@ -61,17 +161,49 @@ export class ProxyController { // === STT === @Post('stt/transcribe') + @ApiTags('STT') + @ApiOperation({ + summary: 'Transcribe audio to text', + description: + 'Converts audio file to text. Costs 10 credits per minute of audio. Supports WAV, MP3, OGG, FLAC.', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { type: 'string', format: 'binary', description: 'Audio file' }, + language: { type: 'string', example: 'en', description: 'Language code (optional)' }, + model: { type: 'string', example: 'base', description: 'Model name (optional)' }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Transcription result' }) + @ApiResponse({ status: 400, description: 'Invalid audio file' }) @UseInterceptors(FileInterceptor('file')) async transcribe(@UploadedFile() file: Express.Multer.File, @Body() body: TranscribeRequestDto) { return this.sttProxy.transcribe(file, body); } @Get('stt/models') + @ApiTags('STT') + @ApiOperation({ + summary: 'Get available STT models', + description: 'Returns a list of available speech-to-text models. Free endpoint.', + }) + @ApiResponse({ status: 200, description: 'List of STT models' }) async getSttModels() { return this.sttProxy.getModels(); } @Get('stt/languages') + @ApiTags('STT') + @ApiOperation({ + summary: 'Get supported languages', + description: 'Returns a list of languages supported by STT. Free endpoint.', + }) + @ApiResponse({ status: 200, description: 'List of supported languages' }) async getSttLanguages() { return this.sttProxy.getLanguages(); } @@ -79,6 +211,34 @@ export class ProxyController { // === TTS === @Post('tts/synthesize') + @ApiTags('TTS') + @ApiOperation({ + summary: 'Synthesize text to speech', + description: + 'Converts text to audio. Costs 1 credit per 1000 characters. Returns audio file (MP3, WAV, or OGG).', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string', example: 'Hello, world! This is a test.' }, + voice: { type: 'string', example: 'en-US-1', description: 'Voice ID' }, + language: { type: 'string', example: 'en-US', description: 'Language code' }, + speed: { type: 'number', example: 1.0, minimum: 0.5, maximum: 2.0 }, + format: { type: 'string', enum: ['mp3', 'wav', 'ogg'], default: 'mp3' }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Audio file', + content: { + 'audio/mpeg': { schema: { type: 'string', format: 'binary' } }, + 'audio/wav': { schema: { type: 'string', format: 'binary' } }, + 'audio/ogg': { schema: { type: 'string', format: 'binary' } }, + }, + }) async synthesize(@Body() body: SynthesizeRequestDto, @Res() res: Response) { const audio = await this.ttsProxy.synthesize(body); @@ -92,11 +252,23 @@ export class ProxyController { } @Get('tts/voices') + @ApiTags('TTS') + @ApiOperation({ + summary: 'Get available voices', + description: 'Returns a list of available TTS voices. Free endpoint.', + }) + @ApiResponse({ status: 200, description: 'List of available voices' }) async getTtsVoices() { return this.ttsProxy.getVoices(); } @Get('tts/languages') + @ApiTags('TTS') + @ApiOperation({ + summary: 'Get supported TTS languages', + description: 'Returns a list of languages supported by TTS. Free endpoint.', + }) + @ApiResponse({ status: 200, description: 'List of supported languages' }) async getTtsLanguages() { return this.ttsProxy.getLanguages(); } diff --git a/services/mana-api-gateway/src/scheduler/scheduler.module.ts b/services/mana-api-gateway/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..d9da4b9e4 --- /dev/null +++ b/services/mana-api-gateway/src/scheduler/scheduler.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { SchedulerService } from './scheduler.service'; + +@Module({ + imports: [ScheduleModule.forRoot()], + providers: [SchedulerService], +}) +export class SchedulerModule {} diff --git a/services/mana-api-gateway/src/scheduler/scheduler.service.ts b/services/mana-api-gateway/src/scheduler/scheduler.service.ts new file mode 100644 index 000000000..58e31b7d3 --- /dev/null +++ b/services/mana-api-gateway/src/scheduler/scheduler.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { apiKeys } from '../db/schema'; + +@Injectable() +export class SchedulerService { + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: ReturnType + ) {} + + /** + * Reset monthly credits on the 1st of each month at 00:00 UTC + */ + @Cron('0 0 1 * *') + async resetMonthlyCredits() { + console.log('[Scheduler] Running monthly credit reset...'); + + try { + const result = await this.db + .update(apiKeys) + .set({ + creditsUsed: 0, + creditsResetAt: this.getNextMonthReset(), + updatedAt: new Date(), + }) + .returning({ id: apiKeys.id }); + + console.log(`[Scheduler] Reset credits for ${result.length} API keys`); + } catch (error) { + console.error('[Scheduler] Failed to reset monthly credits:', error); + } + } + + /** + * Clean up old usage logs (older than 90 days) - runs weekly + */ + @Cron(CronExpression.EVERY_WEEK) + async cleanupOldUsageLogs() { + console.log('[Scheduler] Cleaning up old usage logs...'); + + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 90); + + await this.db.execute( + sql`DELETE FROM api_gateway.api_usage WHERE created_at < ${cutoffDate.toISOString()}` + ); + + console.log('[Scheduler] Cleaned up usage logs older than 90 days'); + } catch (error) { + console.error('[Scheduler] Failed to cleanup usage logs:', error); + } + } + + /** + * Aggregate daily usage stats - runs at 1:00 AM UTC + */ + @Cron('0 1 * * *') + async aggregateDailyUsage() { + console.log('[Scheduler] Daily usage aggregation completed (handled by interceptor)'); + // Note: Daily aggregation is already handled in real-time by UsageTrackingInterceptor + // This cron is a placeholder for any additional daily processing + } + + private getNextMonthReset(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 1); + } +} diff --git a/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts b/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts index 6fea48760..0d98d57d9 100644 --- a/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts +++ b/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts @@ -1,7 +1,14 @@ import { IsOptional, IsString, IsDateString, IsInt, Min, Max } from 'class-validator'; import { Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; export class UsageQueryDto { + @ApiPropertyOptional({ + description: 'Number of days to query (1-365)', + minimum: 1, + maximum: 365, + default: 30, + }) @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @@ -9,14 +16,26 @@ export class UsageQueryDto { @Max(365) days?: number = 30; + @ApiPropertyOptional({ + description: 'Start date for custom range (ISO 8601)', + example: '2025-01-01', + }) @IsOptional() @IsDateString() startDate?: string; + @ApiPropertyOptional({ + description: 'End date for custom range (ISO 8601)', + example: '2025-01-31', + }) @IsOptional() @IsDateString() endDate?: string; + @ApiPropertyOptional({ + description: 'Filter by endpoint', + example: 'search', + }) @IsOptional() @IsString() endpoint?: string;