import { Controller, Post, Get, Body, UseGuards, UseInterceptors, UploadedFile, Req, Res, HttpCode, HttpStatus, Param, Logger, BadRequestException, ServiceUnavailableException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiOkResponse, ApiUnauthorizedResponse, ApiConsumes, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface'; import { MockInterviewService } from './mock-interview.service'; import { GenerateTestDto } from './dto/generate-test.dto'; import { SubmitAnswersDto } from './dto/submit-answers.dto'; import { FollowUpDto } from './dto/follow-up.dto'; import { TtsDto } from './dto/tts.dto'; import { TranscribeDto } from './dto/transcribe.dto'; @ApiTags('Mock Interview') @ApiBearerAuth() @ApiUnauthorizedResponse({ description: 'Missing or invalid JWT token' }) @Controller('mock-interview') @UseGuards(JwtAuthGuard) export class MockInterviewController { private readonly logger = new Logger(MockInterviewController.name); constructor(private readonly service: MockInterviewService) {} @Post('generate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Generate a 3-round mock interview from a job description', description: 'Length depends on lengthTier: quick (~5 min, 5 Q), standard (~10 min, 10 Q), full (~15 min, 15 Q). ' + 'Returns the questions (no answer keys) plus a sessionId to use in /submit.', }) @ApiOkResponse({ description: 'Interview generated successfully', schema: { example: { sessionId: 'uuid', lengthTier: 'full', questions: [ { id: 1, round: 'hr', type: 'behavioral', question: '...' }, { id: 6, round: 'technical', type: 'mcq', question: '...', options: ['A', 'B', 'C', 'D'] }, ], }, }, }) async generate( @Req() req: AuthenticatedRequest, @Body() dto: GenerateTestDto, ) { return this.service.generateTest(req.user.id, dto); } @Post('submit') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Submit answers and get evaluation', description: 'Returns overall score, per-question feedback, and improvement tips.', }) async submit( @Req() req: AuthenticatedRequest, @Body() dto: SubmitAnswersDto, ) { return this.service.submitAnswers(req.user.id, dto); } @Post('follow-up') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Ask for an adaptive follow-up to an open-ended answer', description: 'Returns { followUp }. A one-sentence probing question, or null if none is warranted.', }) async followUp( @Req() req: AuthenticatedRequest, @Body() dto: FollowUpDto, ) { return this.service.requestFollowUp(req.user.id, dto); } @Post('tts') @ApiOperation({ summary: 'Synthesize a natural voice for interviewer speech (Phase 3.4, Kokoro via Python)', description: 'Streams audio (wav or ogg/opus per the format field) piped straight from the Python service. ' + 'On failure returns 503 so the client falls back to browser TTS.', }) async tts(@Body() dto: TtsDto, @Res() res: Response) { try { const { stream, contentType } = await this.service.synthesizeSpeech( dto.text, dto.voice, dto.format, ); res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-store'); res.status(HttpStatus.OK); stream.on('error', (err: Error) => { // Upstream died mid-stream — close what we have; headers are already sent. this.logger.warn(`TTS stream error: ${String(err).slice(0, 160)}`); res.end(); }); stream.pipe(res); } catch (err) { // TTS is optional — never surface a hard error; let the client fall back. this.logger.warn(`TTS unavailable: ${String(err).slice(0, 160)}`); res.status(HttpStatus.SERVICE_UNAVAILABLE).json({ message: 'TTS unavailable' }); } } @Post('transcribe') @HttpCode(HttpStatus.OK) @UseInterceptors(FileInterceptor('audio', { limits: { fileSize: 25 * 1024 * 1024 } })) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Transcribe a spoken answer (Whisper large-v3 via Groq)', description: 'Accepts recorded answer audio (webm/ogg/mp4/wav, field name "audio", ≤25MB) plus an ' + 'optional "context" field (the question) used as a vocabulary hint. Returns { text }. ' + 'On failure returns 503 so the client falls back to the browser transcript.', }) @ApiOkResponse({ schema: { example: { text: 'I led the migration to NestJS…' } } }) async transcribe( @UploadedFile() audio: Express.Multer.File, @Body() dto: TranscribeDto, ) { if (!audio?.buffer?.length) throw new BadRequestException('No audio uploaded.'); try { const text = await this.service.transcribeAnswer( audio.buffer, audio.originalname || 'answer.webm', dto.context, ); return { text }; } catch (err) { // STT is an enhancement — the client keeps the browser transcript on 503. this.logger.warn(`Transcription unavailable: ${String(err).slice(0, 160)}`); throw new ServiceUnavailableException('Transcription unavailable'); } } @Get('sessions') @ApiOperation({ summary: 'Get current user past interview sessions' }) async listSessions(@Req() req: AuthenticatedRequest) { return this.service.getUserSessions(req.user.id); } // Declared before the ':id' route so "progress" isn't captured as an id. @Get('progress') @ApiOperation({ summary: 'Get the user\'s score trend across submitted interviews', description: 'Returns { points: [...], summary: { attempts, best, latest, average, delta } }.', }) async progress(@Req() req: AuthenticatedRequest) { return this.service.getProgress(req.user.id); } @Get(':id') @ApiOperation({ summary: 'Get details of a specific interview session' }) async getSession( @Req() req: AuthenticatedRequest, @Param('id') id: string, ) { return this.service.getSessionDetail(req.user.id, id); } }