changemaker.lite/api/src/modules/media/routes/video-tracking.routes.ts
bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- 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
2026-02-17 15:42:32 -07:00

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(),
};
});
}