- New video card block for GrapesJS landing pages, email templates, MkDocs export, and documentation editor Insert dropdown - Shared HTML generators in admin/src/utils/videoCardHtml.ts - MkDocs video-player.js hydrates .video-card-block elements: thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link - Media API CORS: auto-add MkDocs + docs subdomain origins - env_config_hook.py: smart Docker hostname detection, ADMIN_PORT resolution, pass env vars to MkDocs container - Gallery URL uses /gallery?expanded=ID format - VideoPickerModal: fix double /api prefix and Docker hostname thumbs - Seed: default-video-card PageBlock - Remove V1 legacy code (influence/, map/) Bunker Admin
235 lines
6.0 KiB
TypeScript
235 lines
6.0 KiB
TypeScript
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(),
|
|
};
|
|
});
|
|
}
|