changemaker.lite/api/dist/modules/media/services/video-analytics.service.js

352 lines
13 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videoAnalyticsService = exports.VideoAnalyticsService = void 0;
const database_1 = require("../../../config/database");
const logger_1 = require("../../../utils/logger");
const crypto_1 = require("crypto");
const library_1 = require("@prisma/client/runtime/library");
class VideoAnalyticsService {
/**
* Hash IP address for privacy (SHA-256)
*/
hashIpAddress(ipAddress) {
return (0, crypto_1.createHash)('sha256').update(ipAddress).digest('hex');
}
/**
* Hash user agent for privacy (SHA-256, truncate version numbers first)
*/
hashUserAgent(userAgent) {
// Truncate version numbers for better privacy
const truncated = userAgent
.replace(/\d+\.\d+\.\d+/g, 'X.X.X') // Remove version numbers
.slice(0, 200); // Limit length
return (0, crypto_1.createHash)('sha256').update(truncated).digest('hex');
}
/**
* Record a new video view
*/
async recordView(params) {
const { videoId, userId, ipAddress, userAgent, referer } = params;
try {
const view = await database_1.prisma.videoView.create({
data: {
videoId,
userId: userId || null,
ipAddressHash: ipAddress ? this.hashIpAddress(ipAddress) : null,
userAgentHash: userAgent ? this.hashUserAgent(userAgent) : null,
referer: referer || null,
watchTimeSeconds: 0,
completed: false,
},
});
// Increment view counter on video
await database_1.prisma.video.update({
where: { id: videoId },
data: {
viewCount: {
increment: 1,
},
},
});
logger_1.logger.info(`Recorded view for video ${videoId}`, { viewId: view.id });
return view.id;
}
catch (error) {
logger_1.logger.error('Failed to record video view', { error, videoId });
throw error;
}
}
/**
* Record a video event (play, pause, seek, complete)
*/
async recordEvent(params) {
const { videoId, viewId, eventType, timestamp } = params;
try {
await database_1.prisma.videoEvent.create({
data: {
videoId,
viewId: viewId || null,
eventType,
timestamp: new library_1.Decimal(timestamp),
},
});
// If complete event, mark view as completed and increment finishCount
if (eventType === 'complete' && viewId) {
await database_1.prisma.videoView.update({
where: { id: viewId },
data: { completed: true },
});
await database_1.prisma.video.update({
where: { id: videoId },
data: {
finishCount: {
increment: 1,
},
},
});
}
}
catch (error) {
logger_1.logger.error('Failed to record video event', { error, videoId, eventType });
// Don't throw - we don't want analytics failures to break playback
}
}
/**
* Update watch time for a view (called periodically during playback)
*/
async updateWatchTime(viewId, watchTimeSeconds) {
try {
await database_1.prisma.videoView.update({
where: { id: viewId },
data: {
watchTimeSeconds,
updatedAt: new Date(),
},
});
}
catch (error) {
logger_1.logger.error('Failed to update watch time', { error, viewId });
// Don't throw - we don't want analytics failures to break playback
}
}
/**
* Aggregate analytics for a video
*/
async aggregateVideoAnalytics(videoId) {
try {
const [views, uniqueViewers, totalWatchTime, avgWatchTime, completions] = await Promise.all([
// Total views
database_1.prisma.videoView.count({
where: { videoId },
}),
// Unique viewers (registered users only)
database_1.prisma.videoView.count({
where: {
videoId,
userId: { not: null },
},
distinct: ['userId'],
}),
// Total watch time
database_1.prisma.videoView.aggregate({
where: { videoId },
_sum: {
watchTimeSeconds: true,
},
}),
// Average watch time
database_1.prisma.videoView.aggregate({
where: { videoId },
_avg: {
watchTimeSeconds: true,
},
}),
// Completions
database_1.prisma.videoView.count({
where: {
videoId,
completed: true,
},
}),
]);
// Get video duration for completion rate calculation
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
select: { durationSeconds: true },
});
const completionRate = views > 0 && video?.durationSeconds
? new library_1.Decimal((completions / views) * 100)
: new library_1.Decimal(0);
// Update video analytics fields
await database_1.prisma.video.update({
where: { id: videoId },
data: {
uniqueViewers,
totalWatchTimeSeconds: totalWatchTime._sum.watchTimeSeconds || 0,
averageWatchTimeSeconds: avgWatchTime._avg.watchTimeSeconds
? new library_1.Decimal(avgWatchTime._avg.watchTimeSeconds)
: new library_1.Decimal(0),
completionRate,
},
});
logger_1.logger.info(`Aggregated analytics for video ${videoId}`, {
views,
uniqueViewers,
completionRate: completionRate.toNumber(),
});
}
catch (error) {
logger_1.logger.error('Failed to aggregate video analytics', { error, videoId });
throw error;
}
}
/**
* Get detailed analytics for a video
*/
async getVideoAnalytics(videoId, startDate, endDate) {
const where = { videoId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate)
where.createdAt.gte = startDate;
if (endDate)
where.createdAt.lte = endDate;
}
try {
// Overview stats
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
select: {
viewCount: true,
uniqueViewers: true,
totalWatchTimeSeconds: true,
averageWatchTimeSeconds: true,
completionRate: true,
},
});
if (!video) {
throw new Error('Video not found');
}
// Views over time (last 30 days, grouped by day)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const viewsByDay = await database_1.prisma.$queryRaw `
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM video_views
WHERE video_id = ${videoId}
AND created_at >= ${thirtyDaysAgo}
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
`;
// Top referrers
const topReferrers = await database_1.prisma.videoView.groupBy({
by: ['referer'],
where: {
videoId,
referer: { not: null },
},
_count: {
referer: true,
},
orderBy: {
_count: {
referer: 'desc',
},
},
take: 10,
});
// Registered viewers
const registeredViewers = await database_1.prisma.videoView.findMany({
where: {
videoId,
userId: { not: null },
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 100,
});
return {
overview: {
totalViews: video.viewCount,
uniqueViewers: video.uniqueViewers,
averageWatchTime: video.averageWatchTimeSeconds.toNumber(),
completionRate: video.completionRate.toNumber(),
totalWatchTime: video.totalWatchTimeSeconds,
},
viewsOverTime: viewsByDay.map((row) => ({
date: row.date,
count: Number(row.count),
})),
topReferrers: topReferrers.map((item) => ({
referer: item.referer || 'Direct',
count: item._count.referer,
})),
registeredViewers: registeredViewers.map((view) => ({
userId: view.userId,
userName: view.user?.name || null,
userEmail: view.user?.email || '',
watchTime: view.watchTimeSeconds,
completed: view.completed,
viewedAt: view.createdAt,
})),
};
}
catch (error) {
logger_1.logger.error('Failed to get video analytics', { error, videoId });
throw error;
}
}
/**
* Get top videos by a metric
*/
async getTopVideos(metric, limit = 10) {
try {
const orderBy = metric === 'views' ? { viewCount: 'desc' } : { totalWatchTimeSeconds: 'desc' };
const videos = await database_1.prisma.video.findMany({
select: {
id: true,
title: true,
viewCount: true,
totalWatchTimeSeconds: true,
},
orderBy,
take: limit,
});
return videos.map((video) => ({
id: video.id,
title: video.title,
value: metric === 'views' ? video.viewCount : video.totalWatchTimeSeconds,
}));
}
catch (error) {
logger_1.logger.error('Failed to get top videos', { error, metric });
throw error;
}
}
/**
* Reset analytics for a video
*/
async resetAnalytics(videoId) {
try {
// Delete all views and events
await database_1.prisma.videoView.deleteMany({
where: { videoId },
});
await database_1.prisma.videoEvent.deleteMany({
where: { videoId },
});
// Reset counters
await database_1.prisma.video.update({
where: { id: videoId },
data: {
viewCount: 0,
uniqueViewers: 0,
totalWatchTimeSeconds: 0,
averageWatchTimeSeconds: new library_1.Decimal(0),
completionRate: new library_1.Decimal(0),
finishCount: 0,
},
});
logger_1.logger.info(`Reset analytics for video ${videoId}`);
}
catch (error) {
logger_1.logger.error('Failed to reset analytics', { error, videoId });
throw error;
}
}
}
exports.VideoAnalyticsService = VideoAnalyticsService;
exports.videoAnalyticsService = new VideoAnalyticsService();
//# sourceMappingURL=video-analytics.service.js.map