import { FastifyInstance } from 'fastify'; import { optionalAuth } from '../middleware/auth'; import { videoAnalyticsService } from '../services/video-analytics.service'; import { logger } from '../../../utils/logger'; import { z } from 'zod'; // Validation schemas const recordViewSchema = z.object({ videoId: z.number(), referer: z.string().optional(), }); const recordEventSchema = z.object({ videoId: z.number(), viewId: z.number().optional(), eventType: z.enum(['play', 'pause', 'seek', 'complete']), timestamp: z.number().min(0), }); const updateWatchTimeSchema = z.object({ viewId: z.number(), watchTimeSeconds: z.number().min(0), }); export async function videoTrackingRoutes(fastify: FastifyInstance) { /** * POST /track/view * Record a new video view (called when video starts loading) * Public endpoint - no auth required, but optionally uses auth if available */ fastify.post<{ Body: { videoId: number; referer?: string }; }>( '/view', { preHandler: optionalAuth, }, async (request, reply) => { const { videoId, referer } = request.body; // Validate input try { recordViewSchema.parse(request.body); } catch (error) { return reply.code(400).send({ message: 'Invalid input', error }); } try { // Get user ID if authenticated const userId = (request as any).user?.id; // Get IP address and user agent from request const ipAddress = request.ip; const userAgent = request.headers['user-agent']; // Record the view const viewId = await videoAnalyticsService.recordView({ videoId, userId, ipAddress, userAgent, referer, }); return { success: true, viewId, }; } catch (error) { logger.error('Failed to record view', { error, videoId }); // Don't fail the request - we don't want analytics failures to break playback return { success: false, viewId: null, }; } } ); /** * POST /track/event * Record a video event (play, pause, seek, complete) * Public endpoint - no auth required */ fastify.post<{ Body: { videoId: number; viewId?: number; eventType: 'play' | 'pause' | 'seek' | 'complete'; timestamp: number; }; }>( '/event', async (request, reply) => { const { videoId, viewId, eventType, timestamp } = request.body; // Validate input try { recordEventSchema.parse(request.body); } catch (error) { return reply.code(400).send({ message: 'Invalid input', error }); } try { await videoAnalyticsService.recordEvent({ videoId, viewId, eventType, timestamp, }); return { success: true, }; } catch (error) { logger.error('Failed to record event', { error, videoId, eventType }); // Don't fail the request - analytics failures shouldn't break playback return { success: false, }; } } ); /** * POST /track/heartbeat * Update watch time for a view (called every 10 seconds during playback) * Public endpoint - no auth required */ fastify.post<{ Body: { viewId: number; watchTimeSeconds: number; }; }>( '/heartbeat', async (request, reply) => { const { viewId, watchTimeSeconds } = request.body; // Validate input try { updateWatchTimeSchema.parse(request.body); } catch (error) { return reply.code(400).send({ message: 'Invalid input', error }); } try { await videoAnalyticsService.updateWatchTime(viewId, watchTimeSeconds); return { success: true, }; } catch (error) { logger.error('Failed to update watch time', { error, viewId }); // Don't fail the request return { success: false, }; } } ); /** * POST /track/batch * Batch record multiple events (useful for reducing requests) * Public endpoint - no auth required */ fastify.post<{ Body: { events: Array<{ type: 'view' | 'event' | 'heartbeat'; data: any; }>; }; }>( '/batch', async (request, reply) => { const { events } = request.body; if (!Array.isArray(events) || events.length === 0) { return reply.code(400).send({ message: 'Events array is required' }); } if (events.length > 50) { return reply.code(400).send({ message: 'Maximum 50 events per batch' }); } try { const results = await Promise.allSettled( events.map(async (event) => { switch (event.type) { case 'view': return videoAnalyticsService.recordView(event.data); case 'event': return videoAnalyticsService.recordEvent(event.data); case 'heartbeat': return videoAnalyticsService.updateWatchTime( event.data.viewId, event.data.watchTimeSeconds ); default: throw new Error(`Unknown event type: ${event.type}`); } }) ); const successful = results.filter((r) => r.status === 'fulfilled').length; const failed = results.filter((r) => r.status === 'rejected').length; return { success: true, total: events.length, successful, failed, }; } catch (error) { logger.error('Failed to process batch tracking', { error }); return reply.code(500).send({ message: 'Failed to process batch' }); } } ); /** * GET /track/health * Health check for tracking endpoints */ fastify.get('/health', async (request, reply) => { return { status: 'ok', service: 'video-tracking', timestamp: new Date().toISOString(), }; }); }