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:
Till-JS 2026-01-29 18:03:16 +01:00
parent 4b322f59b1
commit fc0ed636fc
21 changed files with 1059 additions and 1 deletions

View 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);
}
}

View 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 {}

View 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,
};
}
}

View 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;
}