"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.findMany({ where: { videoId, userId: { not: null }, }, distinct: ['userId'], select: { userId: true }, }).then(views => views.length), // 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