197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.videoTrackingRoutes = videoTrackingRoutes;
|
|
const auth_1 = require("../middleware/auth");
|
|
const video_analytics_service_1 = require("../services/video-analytics.service");
|
|
const logger_1 = require("../../../utils/logger");
|
|
const zod_1 = require("zod");
|
|
// Validation schemas
|
|
const recordViewSchema = zod_1.z.object({
|
|
videoId: zod_1.z.number(),
|
|
referer: zod_1.z.string().optional(),
|
|
});
|
|
const recordEventSchema = zod_1.z.object({
|
|
videoId: zod_1.z.number(),
|
|
viewId: zod_1.z.number().optional(),
|
|
eventType: zod_1.z.enum(['play', 'pause', 'seek', 'complete']),
|
|
timestamp: zod_1.z.number().min(0),
|
|
});
|
|
const updateWatchTimeSchema = zod_1.z.object({
|
|
viewId: zod_1.z.number(),
|
|
watchTimeSeconds: zod_1.z.number().min(0),
|
|
});
|
|
async function videoTrackingRoutes(fastify) {
|
|
/**
|
|
* 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('/view', {
|
|
preHandler: auth_1.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.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 video_analytics_service_1.videoAnalyticsService.recordView({
|
|
videoId,
|
|
userId,
|
|
ipAddress,
|
|
userAgent,
|
|
referer,
|
|
});
|
|
return {
|
|
success: true,
|
|
viewId,
|
|
};
|
|
}
|
|
catch (error) {
|
|
logger_1.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('/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 video_analytics_service_1.videoAnalyticsService.recordEvent({
|
|
videoId,
|
|
viewId,
|
|
eventType,
|
|
timestamp,
|
|
});
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
catch (error) {
|
|
logger_1.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('/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 video_analytics_service_1.videoAnalyticsService.updateWatchTime(viewId, watchTimeSeconds);
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
catch (error) {
|
|
logger_1.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('/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 {
|
|
// Validate each event's data against per-type schemas before processing
|
|
for (const event of events) {
|
|
switch (event.type) {
|
|
case 'view':
|
|
recordViewSchema.parse(event.data);
|
|
break;
|
|
case 'event':
|
|
recordEventSchema.parse(event.data);
|
|
break;
|
|
case 'heartbeat':
|
|
updateWatchTimeSchema.parse(event.data);
|
|
break;
|
|
default:
|
|
return reply.code(400).send({ message: `Unknown event type: ${event.type}` });
|
|
}
|
|
}
|
|
const results = await Promise.allSettled(events.map(async (event) => {
|
|
switch (event.type) {
|
|
case 'view':
|
|
return video_analytics_service_1.videoAnalyticsService.recordView(event.data);
|
|
case 'event':
|
|
return video_analytics_service_1.videoAnalyticsService.recordEvent(event.data);
|
|
case 'heartbeat':
|
|
return video_analytics_service_1.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_1.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(),
|
|
};
|
|
});
|
|
}
|
|
//# sourceMappingURL=video-tracking.routes.js.map
|