"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.publicMediaRoutes = publicMediaRoutes; const database_1 = require("../../../config/database"); const auth_1 = require("../middleware/auth"); const logger_1 = require("../../../utils/logger"); const session_service_1 = require("../services/session.service"); const public_media_schemas_1 = require("../schemas/public-media.schemas"); const fs_1 = require("fs"); const promises_1 = require("fs/promises"); /** * Public Media Gallery API Routes * Handles public video listing, upvotes, comments, and admin operations */ async function publicMediaRoutes(fastify) { /** * GET /videos (LEGACY ROUTE) * Compatibility endpoint for public-media app (port 3100) * Converts page-based pagination to offset-based and transforms response format */ fastify.get('/videos', { preHandler: auth_1.optionalAuth, }, async (request, reply) => { try { // Convert page-based to offset-based pagination const page = parseInt(request.query.page || '1'); const limit = parseInt(request.query.limit || '48'); const offset = (page - 1) * limit; const { search, category, sort } = request.query; // Check if user is admin const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN']; const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role); // Build WHERE clause (same logic as /public endpoint) const where = { isPublished: true, // Only show published videos }; if (!isAdmin) { where.isLocked = false; } if (category) { where.category = category; } if (search) { where.filename = { contains: search, mode: 'insensitive', }; } // Determine sort order let orderBy = {}; switch (sort) { case 'recent': orderBy = { publishedAt: 'desc' }; break; case 'popular': orderBy = { upvoteCount: 'desc' }; break; case 'most_viewed': orderBy = { viewCount: 'desc' }; break; default: orderBy = { publishedAt: 'desc' }; } // Execute queries in parallel const [videos, total] = await Promise.all([ database_1.prisma.video.findMany({ where, select: { id: true, filename: true, category: true, durationSeconds: true, quality: true, orientation: true, thumbnailPath: true, fileSize: true, viewCount: true, upvoteCount: true, commentCount: true, createdAt: true, }, orderBy, take: limit, skip: offset, }), database_1.prisma.video.count({ where }), ]); // Calculate total pages const totalPages = Math.ceil(total / limit); // Transform to legacy format expected by public-media app return { data: videos.map((video) => ({ id: video.id.toString(), // int → string title: video.filename.replace(/\.[^.]+$/, ''), // filename without extension fileName: video.filename, // camelCase fileSize: Number(video.fileSize || 0), // BigInt → number duration: video.durationSeconds || 0, // rename field width: 0, // not stored, placeholder height: 0, // not stored, placeholder orientation: video.orientation || 'horizontal', category: video.category, viewCount: video.viewCount || 0, createdAt: video.createdAt.toISOString(), updatedAt: video.createdAt.toISOString(), // no updatedAt field, use createdAt thumbnailUrl: video.thumbnailPath ? `/api/media/public/${video.id}/thumbnail` : undefined, streamUrl: `/media/public/${video.category}/${video.filename}`, // static file path })), total, page, limit, totalPages, }; } catch (error) { logger_1.logger.error('Error fetching videos (legacy endpoint):', error); return reply.code(500).send({ message: 'Failed to fetch videos', error: error.message, }); } }); /** * GET /public * List public videos with filtering, sorting, and pagination */ fastify.get('/public', { preHandler: auth_1.optionalAuth, }, async (request, reply) => { try { // Validate query params const parseResult = public_media_schemas_1.listPublicMediaSchema.safeParse(request.query); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid query parameters', errors: parseResult.error.errors, }); } const { limit, offset, sort, search, category } = parseResult.data; // Check if user is admin const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN']; const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role); // Build WHERE clause const where = { isPublished: true, // Only show published videos }; // Non-admins can't see locked videos if (!isAdmin) { where.isLocked = false; } // Category filter if (category) { where.category = category; } // Search filter (searches filename) if (search) { where.filename = { contains: search, mode: 'insensitive', }; } // Determine sort order let orderBy = {}; switch (sort) { case 'recent': orderBy = { publishedAt: 'desc' }; break; case 'popular': orderBy = { upvoteCount: 'desc' }; break; case 'most_viewed': orderBy = { viewCount: 'desc' }; break; default: orderBy = { publishedAt: 'desc' }; } // Execute queries in parallel const [videos, total] = await Promise.all([ database_1.prisma.video.findMany({ where, select: { id: true, filename: true, category: true, durationSeconds: true, quality: true, orientation: true, thumbnailPath: true, fileSize: true, viewCount: true, upvoteCount: true, commentCount: true, createdAt: true, isLocked: true, position: true, publishedAt: true, }, orderBy, take: limit, skip: offset, }), database_1.prisma.video.count({ where }), ]); return { videos, pagination: { total, limit, offset, hasMore: offset + limit < total, }, }; } catch (error) { logger_1.logger.error('Error listing public media:', error); return reply.code(500).send({ message: 'Failed to list videos', error: error.message, }); } }); /** * GET /public/:id * Get single video details */ fastify.get('/public/:id', { preHandler: auth_1.optionalAuth, }, async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, // Only show published videos }, select: { id: true, filename: true, category: true, durationSeconds: true, quality: true, orientation: true, thumbnailPath: true, fileSize: true, viewCount: true, upvoteCount: true, commentCount: true, finishCount: true, totalWatchTime: true, createdAt: true, publishedAt: true, isLocked: true, position: true, uploaderId: true, }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } // Check if locked and user is not admin const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN']; const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role); if (video.isLocked && !isAdmin) { return reply.code(403).send({ message: 'This video is locked', }); } return { video }; } catch (error) { logger_1.logger.error('Error fetching public media:', error); return reply.code(500).send({ message: 'Failed to fetch video', error: error.message, }); } }); /** * POST /public/:id/upvote * Toggle upvote for a video */ fastify.post('/public/:id/upvote', async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Get or create session let sessionId; try { sessionId = await (0, session_service_1.getOrCreateSession)(request); } catch (error) { return reply.code(400).send({ message: 'Session required', error: error.message, }); } // Check if video exists and is published const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, }, select: { id: true }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } // Check if upvote already exists const existingUpvote = await database_1.prisma.upvote.findFirst({ where: { mediaId: videoId, sessionId, }, }); if (existingUpvote) { // Remove upvote (toggle off) await database_1.prisma.$transaction([ database_1.prisma.upvote.delete({ where: { id: existingUpvote.id }, }), database_1.prisma.video.update({ where: { id: videoId }, data: { upvoteCount: { decrement: 1, }, }, }), ]); logger_1.logger.info(`Removed upvote for video ${videoId} from session ${sessionId}`); return { upvoted: false }; } else { // Add upvote (toggle on) await database_1.prisma.$transaction([ database_1.prisma.upvote.create({ data: { mediaId: videoId, sessionId, }, }), database_1.prisma.video.update({ where: { id: videoId }, data: { upvoteCount: { increment: 1, }, }, }), ]); logger_1.logger.info(`Added upvote for video ${videoId} from session ${sessionId}`); return { upvoted: true }; } } catch (error) { logger_1.logger.error('Error toggling upvote:', error); return reply.code(500).send({ message: 'Failed to toggle upvote', error: error.message, }); } }); /** * GET /public/:id/upvote-status * Check if current session has upvoted a video */ fastify.get('/public/:id/upvote-status', async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Read sessionId from header (don't create if missing) const sessionId = request.headers['x-session-id']; if (!sessionId) { return { upvoted: false }; } // Check if upvote exists const upvote = await database_1.prisma.upvote.findFirst({ where: { mediaId: videoId, sessionId, }, }); return { upvoted: !!upvote }; } catch (error) { logger_1.logger.error('Error checking upvote status:', error); return reply.code(500).send({ message: 'Failed to check upvote status', error: error.message, }); } }); /** * GET /public/:id/comments * List comments for a video */ fastify.get('/public/:id/comments', async (request, reply) => { try { const videoId = parseInt(request.params.id); const limit = parseInt(request.query.limit || '20'); const offset = parseInt(request.query.offset || '0'); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Check if video exists and is published const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, }, select: { id: true }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } // Fetch comments (hide hidden ones) const [comments, total] = await Promise.all([ database_1.prisma.comment.findMany({ where: { mediaId: videoId, isHidden: false, }, select: { id: true, content: true, createdAt: true, sessionId: true, userId: true, safetyStatus: true, user: { select: { name: true, email: true, }, }, }, orderBy: { createdAt: 'desc', }, take: limit, skip: offset, }), database_1.prisma.comment.count({ where: { mediaId: videoId, isHidden: false, }, }), ]); return { comments, pagination: { total, limit, offset, hasMore: offset + limit < total, }, }; } catch (error) { logger_1.logger.error('Error listing comments:', error); return reply.code(500).send({ message: 'Failed to list comments', error: error.message, }); } }); /** * POST /public/:id/comments * Add a comment to a video */ fastify.post('/public/:id/comments', async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Validate request body const parseResult = public_media_schemas_1.addCommentSchema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid comment data', errors: parseResult.error.errors, }); } const { content } = parseResult.data; // Get or create session let sessionId; try { sessionId = await (0, session_service_1.getOrCreateSession)(request); } catch (error) { return reply.code(400).send({ message: 'Session required', error: error.message, }); } // Check if video exists and is published const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, }, select: { id: true }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } // Create comment and increment counter in transaction const [comment] = await database_1.prisma.$transaction([ database_1.prisma.comment.create({ data: { mediaId: videoId, sessionId, userId: request.user?.id || null, content, safetyStatus: 'pending', }, select: { id: true, content: true, createdAt: true, sessionId: true, userId: true, safetyStatus: true, }, }), database_1.prisma.video.update({ where: { id: videoId }, data: { commentCount: { increment: 1, }, }, }), ]); logger_1.logger.info(`Added comment ${comment.id} for video ${videoId} from session ${sessionId}`); return { comment }; } catch (error) { logger_1.logger.error('Error adding comment:', error); return reply.code(500).send({ message: 'Failed to add comment', error: error.message, }); } }); /** * GET /public/:id/thumbnail * Serve thumbnail image for a video */ fastify.get('/public/:id/thumbnail', async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Fetch video with thumbnail path (published only) const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, }, select: { thumbnailPath: true, }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } if (!video.thumbnailPath) { return reply.code(404).send({ message: 'Thumbnail not found' }); } // Check if file exists try { await (0, promises_1.access)(video.thumbnailPath, promises_1.constants.R_OK); } catch { logger_1.logger.warn(`Thumbnail file not found: ${video.thumbnailPath}`); return reply.code(404).send({ message: 'Thumbnail file not found' }); } // Stream the file const stream = (0, fs_1.createReadStream)(video.thumbnailPath); // Set content type based on file extension const ext = video.thumbnailPath.toLowerCase().split('.').pop(); const contentType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; return reply.type(contentType).send(stream); } catch (error) { logger_1.logger.error('Error serving thumbnail:', error); return reply.code(500).send({ message: 'Failed to serve thumbnail', error: error.message, }); } }); /** * POST /public/bulk-lock * Lock multiple videos (admin only) */ fastify.post('/public/bulk-lock', { preHandler: auth_1.requireAdminRole, }, async (request, reply) => { try { // Validate request body const parseResult = public_media_schemas_1.bulkLockSchema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid request', errors: parseResult.error.errors, }); } const { ids } = parseResult.data; const userId = request.user?.id; // Update all videos at once (only published videos) const result = await database_1.prisma.video.updateMany({ where: { id: { in: ids, }, isPublished: true, }, data: { isLocked: true, lockedAt: new Date(), lockedById: userId, }, }); logger_1.logger.info(`Locked ${result.count} videos (IDs: ${ids.join(', ')}) by user ${userId}`); return { success: true, count: result.count, }; } catch (error) { logger_1.logger.error('Error bulk locking videos:', error); return reply.code(500).send({ message: 'Failed to lock videos', error: error.message, }); } }); /** * POST /public/bulk-unlock * Unlock multiple videos (admin only) */ fastify.post('/public/bulk-unlock', { preHandler: auth_1.requireAdminRole, }, async (request, reply) => { try { // Validate request body const parseResult = public_media_schemas_1.bulkUnlockSchema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid request', errors: parseResult.error.errors, }); } const { ids } = parseResult.data; const userId = request.user?.id; // Update all videos at once (only published videos) const result = await database_1.prisma.video.updateMany({ where: { id: { in: ids, }, isPublished: true, }, data: { isLocked: false, lockedAt: null, lockedById: null, }, }); logger_1.logger.info(`Unlocked ${result.count} videos (IDs: ${ids.join(', ')}) by user ${userId}`); return { success: true, count: result.count, }; } catch (error) { logger_1.logger.error('Error bulk unlocking videos:', error); return reply.code(500).send({ message: 'Failed to unlock videos', error: error.message, }); } }); /** * DELETE /public/:id * Unpublish a video from public gallery (admin only) * NOTE: This unpublishes instead of deleting - interactions are preserved */ fastify.delete('/public/:id', { preHandler: auth_1.requireAdminRole, }, async (request, reply) => { try { const videoId = parseInt(request.params.id); if (isNaN(videoId)) { return reply.code(400).send({ message: 'Invalid video ID' }); } // Check if video exists and is published const video = await database_1.prisma.video.findFirst({ where: { id: videoId, isPublished: true, }, select: { id: true }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } // Unpublish the video (preserves upvotes, comments, views) await database_1.prisma.video.update({ where: { id: videoId }, data: { isPublished: false, publishedAt: null, // Keep category for re-publishing }, }); logger_1.logger.info(`Unpublished video ${videoId} by user ${request.user?.id}`); return { success: true }; } catch (error) { logger_1.logger.error('Error unpublishing video:', error); return reply.code(500).send({ message: 'Failed to unpublish video', error: error.message, }); } }); } //# sourceMappingURL=public-media.routes.js.map