mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
✨ feat(api-gateway): add Swagger, admin endpoints, and scheduler
- 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
This commit is contained in:
parent
4b322f59b1
commit
fc0ed636fc
21 changed files with 1059 additions and 1 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
118
services/mana-api-gateway/src/admin/admin.controller.ts
Normal file
118
services/mana-api-gateway/src/admin/admin.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
services/mana-api-gateway/src/admin/admin.module.ts
Normal file
10
services/mana-api-gateway/src/admin/admin.module.ts
Normal file
|
|
@ -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 {}
|
||||
264
services/mana-api-gateway/src/admin/admin.service.ts
Normal file
264
services/mana-api-gateway/src/admin/admin.service.ts
Normal file
|
|
@ -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<typeof import('../db/connection').getDb>
|
||||
) {}
|
||||
|
||||
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<number>`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<number>`sum(${apiUsageDaily.requestCount})`,
|
||||
creditsUsed: sql<number>`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<string, unknown> = {
|
||||
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<number>`sum(${apiUsageDaily.requestCount})`,
|
||||
creditsUsed: sql<number>`sum(${apiUsageDaily.creditsUsed})`,
|
||||
errorCount: sql<number>`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<number>`sum(${apiUsageDaily.requestCount})`,
|
||||
creditsUsed: sql<number>`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<number>`count(*)`,
|
||||
activeCount: sql<number>`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<number>`sum(${apiUsageDaily.requestCount})`,
|
||||
creditsUsed: sql<number>`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,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts
Normal file
100
services/mana-api-gateway/src/admin/dto/admin-update-key.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
37
services/mana-api-gateway/src/guards/admin.guard.ts
Normal file
37
services/mana-api-gateway/src/guards/admin.guard.ts
Normal file
|
|
@ -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<string>('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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
72
services/mana-api-gateway/src/scheduler/scheduler.service.ts
Normal file
72
services/mana-api-gateway/src/scheduler/scheduler.service.ts
Normal file
|
|
@ -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<typeof import('../db/connection').getDb>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue