changemaker.lite/api/dist/modules/media/routes/public-media.routes.js

740 lines
27 KiB
JavaScript

"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