import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; import { AdminGuard } from '../../common/guards/admin.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { ApiError } from '../../common/errors/api-error'; import { ERROR_CODES } from '../../common/errors/error-codes'; import { ChallengeIdParamSchema, CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION, CreateChallengeSchema, ImportDocumentSchema, ImportQuerySchema, ListChallengesQuerySchema, UpdateChallengeSchema, } from './dto/challenges.dto'; import { AdminChallengesService, ChallengeDetailView, ChallengeListItem, ImportPreview, ImportResult, } from './challenges.service'; import { ChallengeFilesService } from './challenge-files.service'; @ApiTags('admin') @ApiBearerAuth() @Controller('api/v1/admin/challenges') @UseGuards(AdminGuard) @Roles('admin') export class AdminChallengesController { constructor( private readonly challenges: AdminChallengesService, private readonly files: ChallengeFilesService, ) {} @Get() @ApiOperation({ summary: 'List all challenges (admin only)' }) async list( @Query(new ZodValidationPipe(ListChallengesQuerySchema)) query: { q?: string; categoryId?: string }, ): Promise { return this.challenges.list(query); } @Get('export') @ApiOperation({ summary: 'Export all challenges (admin only)' }) async exportAll(@Res() res: Response): Promise { const doc = await this.challenges.exportAll(); const date = new Date().toISOString().slice(0, 10); res .status(HttpStatus.OK) .setHeader('Content-Type', 'application/json; charset=utf-8') .setHeader( 'Content-Disposition', `attachment; filename="challenges-export-${date}.json"`, ) .send(JSON.stringify(doc)); } @Post('import') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Import challenges; two-phase (preview/confirm) (admin only)' }) async import( @Body(new ZodValidationPipe(ImportDocumentSchema)) body: any, @Query(new ZodValidationPipe(ImportQuerySchema)) query: { confirm?: string }, ): Promise { if (query.confirm !== 'overwrite') { const preview = await this.challenges.previewImport(body); return preview; } const result = await this.challenges.importConfirmed(body); return result; } @Post('files/stage') @HttpCode(HttpStatus.CREATED) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: 'Stage a challenge attachment for later commit (admin only)' }) async stageFile(@UploadedFile() file: any): Promise<{ token: string; originalFilename: string; mimeType: string; sizeBytes: number; }> { if (!file) throw new BadRequestException('No file uploaded under field "file"'); const buffer = file.buffer ?? Buffer.alloc(0); const record = this.files.stage(buffer, file.originalname ?? 'file', file.mimetype ?? 'application/octet-stream'); return { token: record.token, originalFilename: record.originalFilename, mimeType: record.mimeType, sizeBytes: record.sizeBytes, }; } @Delete('files/stage/:token') @ApiOperation({ summary: 'Discard a staged challenge attachment (admin only)' }) discardFile(@Param('token') token: string): { ok: true } { this.files.discard(token); return { ok: true }; } @Get(':id') @ApiOperation({ summary: 'Get a single challenge with details (admin only)' }) async detail( @Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string }, ): Promise { return this.challenges.getDetail(params.id); } @Post() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create a challenge (admin only)' }) async create( @Body(new ZodValidationPipe(CreateChallengeSchema)) body: any, ): Promise { return this.challenges.create(body); } @Put(':id') @ApiOperation({ summary: 'Update a challenge (admin only)' }) async update( @Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string }, @Body(new ZodValidationPipe(UpdateChallengeSchema)) body: any, ): Promise { return this.challenges.update(params.id, body); } @Delete(':id') @ApiOperation({ summary: 'Delete a challenge (admin only)' }) async remove( @Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string }, ): Promise { await this.challenges.remove(params.id); } } // Expose constants for tests/UI without circular imports. export { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION }; export { ERROR_CODES }; export { ApiError };