mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 07:46:42 +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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue