import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fastifyMultipart from '@fastify/multipart'; import { voiceEnrollmentService, analysisService, batchAnalysisService, voicePrintEnv, } from '../services/voiceprint'; interface AuthenticatedRequest extends FastifyRequest { user?: { id: string; email: string; role: string }; authType?: 'jwt' | 'api-key' | 'anonymous'; } export async function voiceprintRoutes(fastify: FastifyInstance) { // P1-2 fix: Require authentication on all VoicePrint routes fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; if (authReq.authType === 'anonymous' || !authReq.user?.id || authReq.user.id === 'anonymous') { return reply.code(401).send({ error: 'Authentication required' }); } }); // P1-3 fix: Register multipart for audio file uploads await fastify.register(fastifyMultipart, { limits: { fileSize: voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC > 0 ? 50 * 1024 * 1024 // 50MB max file size for audio : 50 * 1024 * 1024, }, }); // Enroll a new voice profile fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } // P1-3 fix: Parse multipart form-data for audio upload let name: string | undefined; let audioBuffer: Buffer | undefined; for await (const part of request.files()) { if (part.type === 'file') { audioBuffer = await part.toBuffer(); name = name || part.filename || 'voice_enrollment'; } else if (part.fieldname === 'name') { name = part.value; } } if (!audioBuffer || audioBuffer.length === 0) { return reply.code(400).send({ error: 'audio file is required' }); } try { const enrollment = await voiceEnrollmentService.enroll( userId, name || 'voice_enrollment', audioBuffer ); return reply.code(201).send({ enrollment: { id: enrollment.id, name: enrollment.name, isActive: enrollment.isActive, createdAt: enrollment.createdAt, }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Enrollment failed'; return reply.code(422).send({ error: message }); } }); // List user's voice enrollments fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } const isActive = request.query as { isActive?: string }; const limit = request.query as { limit?: string }; const offset = request.query as { offset?: string }; const enrollments = await voiceEnrollmentService.listEnrollments(userId, { isActive: isActive.isActive !== undefined ? isActive.isActive === 'true' : undefined, limit: limit.limit ? parseInt(limit.limit, 10) : undefined, offset: offset.offset ? parseInt(offset.offset, 10) : undefined, }); return reply.send({ enrollments: enrollments.map((e) => ({ id: e.id, name: e.name, isActive: e.isActive, createdAt: e.createdAt, })), }); }); // Remove an enrollment fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } const enrollmentId = (request.params as { id: string }).id; try { const enrollment = await voiceEnrollmentService.removeEnrollment( enrollmentId, userId ); return reply.send({ enrollment: { id: enrollment.id, name: enrollment.name, isActive: enrollment.isActive, }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Removal failed'; return reply.code(404).send({ error: message }); } }); // Analyze a single audio file fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } // P1-3 fix: Parse multipart form-data for audio upload let audioBuffer: Buffer | undefined; let enrollmentId: string | undefined; let audioUrl: string | undefined; for await (const part of request.files()) { if (part.type === 'file') { audioBuffer = await part.toBuffer(); } else if (part.fieldname === 'enrollmentId') { enrollmentId = part.value; } else if (part.fieldname === 'audioUrl') { audioUrl = part.value; } } if (!audioBuffer || audioBuffer.length === 0) { return reply.code(400).send({ error: 'audio file is required' }); } try { const result = await analysisService.analyze(userId, audioBuffer, { enrollmentId, audioUrl, }); return reply.code(201).send({ analysis: { id: result.id, isSynthetic: result.isSynthetic, confidence: result.confidence, analysisResult: result.analysisResult, createdAt: result.createdAt, }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Analysis failed'; return reply.code(422).send({ error: message }); } }); // Get analysis result by ID fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } const analysisId = (request.params as { id: string }).id; const result = await analysisService.getResult(analysisId, userId); if (!result) { return reply.code(404).send({ error: 'Analysis not found' }); } return reply.send({ analysis: { id: result.id, isSynthetic: result.isSynthetic, confidence: result.confidence, analysisResult: result.analysisResult, createdAt: result.createdAt, }, }); }); // Get analysis history fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } const query = request.query as { limit?: string; offset?: string; isSynthetic?: string; }; const results = await analysisService.getHistory(userId, { limit: query.limit ? parseInt(query.limit, 10) : undefined, offset: query.offset ? parseInt(query.offset, 10) : undefined, isSynthetic: query.isSynthetic !== undefined ? query.isSynthetic === 'true' : undefined, }); return reply.send({ analyses: results.map((r) => ({ id: r.id, isSynthetic: r.isSynthetic, confidence: r.confidence, createdAt: r.createdAt, })), }); }); // Batch analyze multiple audio files fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthenticatedRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User ID required' }); } // P1-3 fix: Parse multipart form-data for multiple audio uploads const files: Array<{ name: string; buffer: Buffer; audioUrl?: string }> = []; let enrollmentId: string | undefined; for await (const part of request.files()) { if (part.type === 'file') { const buffer = await part.toBuffer(); files.push({ name: part.filename || `file_${files.length}`, buffer, }); } else if (part.fieldname === 'enrollmentId') { enrollmentId = part.value; } else if (part.fieldname === 'audioUrl') { if (files.length > 0) { files[files.length - 1].audioUrl = part.value; } } } if (files.length === 0) { return reply.code(400).send({ error: 'at least one audio file is required' }); } try { const result = await batchAnalysisService.analyzeBatch( userId, files, { enrollmentId } ); return reply.code(201).send({ jobId: result.jobId, results: result.results.map((r) => ({ id: r.id, isSynthetic: r.isSynthetic, confidence: r.confidence, })), summary: result.summary, }); } catch (error) { const message = error instanceof Error ? error.message : 'Batch analysis failed'; return reply.code(422).send({ error: message }); } }); }