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
This commit is contained in:
@@ -97,6 +97,10 @@ const envSchema = z.object({
|
||||
EXCALIDRAW_PORT: z.coerce.number().default(8090),
|
||||
EXCALIDRAW_EMBED_PORT: z.coerce.number().default(8886),
|
||||
|
||||
// Homepage (service dashboard)
|
||||
HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'),
|
||||
HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887),
|
||||
|
||||
// Pangolin (tunnel / reverse proxy)
|
||||
PANGOLIN_API_URL: z.string()
|
||||
.default('')
|
||||
|
||||
@@ -66,9 +66,25 @@ process.on('uncaughtException', (error) => {
|
||||
// Start server
|
||||
const start = async () => {
|
||||
try {
|
||||
// CORS configuration
|
||||
// CORS configuration — allow admin app + MkDocs docs site
|
||||
const allowedOrigins = env.CORS_ORIGINS.split(',').map(o => o.trim());
|
||||
|
||||
// Auto-add MkDocs origins so video cards/players work in docs
|
||||
const mkdocsOrigin = `http://localhost:${env.MKDOCS_PORT || 4003}`;
|
||||
if (!allowedOrigins.includes(mkdocsOrigin)) {
|
||||
allowedOrigins.push(mkdocsOrigin);
|
||||
}
|
||||
// Also allow the docs subdomain in production (docs.domain.org)
|
||||
for (const origin of [...allowedOrigins]) {
|
||||
const match = origin.match(/^(https?:\/\/)app\./);
|
||||
if (match) {
|
||||
const docsOrigin = origin.replace(/^(https?:\/\/)app\./, '$1docs.');
|
||||
if (!allowedOrigins.includes(docsOrigin)) {
|
||||
allowedOrigins.push(docsOrigin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
|
||||
@@ -26,10 +26,10 @@ interface ReorderBody {
|
||||
|
||||
export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/media/playlists - All playlists (admin)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/playlists',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '25'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@@ -101,7 +101,7 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/playlists/featured',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const featured = await prisma.featuredPlaylist.findMany({
|
||||
orderBy: { position: 'asc' },
|
||||
include: {
|
||||
@@ -154,10 +154,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/media/playlists/:id/feature - Feature a playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/feature',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -202,10 +202,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/media/playlists/:id/feature - Unfeature a playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/feature',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -225,10 +225,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/media/playlists/featured/reorder - Reorder featured playlists
|
||||
fastify.put(
|
||||
fastify.put<{ Body: ReorderBody }>(
|
||||
'/playlists/featured/reorder',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Body: ReorderBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { items } = request.body;
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return reply.code(400).send({ message: 'items array is required' });
|
||||
@@ -248,10 +248,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/media/playlists/:id - Admin update playlist metadata
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>(
|
||||
'/playlists/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -303,10 +303,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/media/playlists/:id/duplicate - Duplicate a playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/duplicate',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -361,10 +361,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/media/playlists/:id - Admin delete any playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@@ -89,10 +89,10 @@ function formatPlaylistSummary(playlist: any, requestUserId?: string) {
|
||||
|
||||
export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/playlists/featured - Get featured playlists
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/featured',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '12'), 50);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
|
||||
@@ -121,10 +121,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/popular - Get popular public playlists
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/popular',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '12'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@@ -157,10 +157,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/share/:token - Get playlist by share token
|
||||
fastify.get(
|
||||
fastify.get<{ Params: ShareParams }>(
|
||||
'/share/:token',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Params: ShareParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
const playlist = await prisma.playlist.findUnique({
|
||||
@@ -228,13 +228,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/:id - Get playlist detail
|
||||
fastify.get(
|
||||
fastify.get<{ Params: PlaylistParams; Querystring: PlaylistIdQuery }>(
|
||||
'/:id',
|
||||
{ preHandler: optionalAuth },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Querystring: PlaylistIdQuery }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -315,10 +312,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/view - Record playlist view
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/:id/view',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@@ -80,7 +80,7 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/my',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const playlists = await prisma.playlist.findMany({
|
||||
@@ -139,10 +139,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/ - Create playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Body: CreatePlaylistBody }>(
|
||||
'/',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Body: CreatePlaylistBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { name, description, isPublic } = request.body;
|
||||
|
||||
@@ -174,13 +174,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/playlists/:id - Update playlist
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>(
|
||||
'/:id',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -227,10 +224,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id - Delete playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/:id',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -250,13 +247,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/videos - Add video to playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams; Body: AddVideoBody }>(
|
||||
'/:id/videos',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: AddVideoBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -316,10 +310,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id/videos/:mediaId - Remove video from playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: VideoParams }>(
|
||||
'/:id/videos/:mediaId',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: VideoParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
const mediaId = parseInt(request.params.mediaId);
|
||||
|
||||
@@ -350,13 +344,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/playlists/:id/videos/reorder - Reorder videos
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: ReorderBody }>(
|
||||
'/:id/videos/reorder',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: ReorderBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -394,10 +385,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/share - Generate share token
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/:id/share',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@@ -422,10 +413,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id/share - Revoke share token
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/:id/share',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
@@ -22,12 +22,12 @@ interface PublicVideosQuery {
|
||||
|
||||
export async function publicRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/public - List published videos (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PublicVideosQuery }>(
|
||||
'/public',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: PublicVideosQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '24');
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@@ -112,12 +112,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/public/:id - Get single published video (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
@@ -181,12 +181,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
});
|
||||
|
||||
// GET /api/public/:id/thumbnail - Get video thumbnail (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id/thumbnail',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
@@ -245,12 +245,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/public/:id/stream - Stream video file (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id/stream',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcastReactionToVideo } from './chat-stream.routes.js';
|
||||
@@ -43,12 +43,12 @@ interface GetReactionsQuery {
|
||||
|
||||
export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
// Add reaction (authenticated users only)
|
||||
fastify.post(
|
||||
fastify.post<{ Body: AddReactionBody }>(
|
||||
'/',
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: AddReactionBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { mediaId, reactionType, videoTimestamp } = request.body;
|
||||
const userId = request.user!.id;
|
||||
|
||||
@@ -130,9 +130,9 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get reactions
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: GetReactionsQuery }>(
|
||||
'/',
|
||||
async (request: FastifyRequest<{ Querystring: GetReactionsQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { mediaId, userId, limit = '50' } = request.query;
|
||||
|
||||
const where: any = {};
|
||||
@@ -169,7 +169,7 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get reaction config (returns available reactions)
|
||||
fastify.get('/config', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/config', async (request, reply) => {
|
||||
return {
|
||||
reactions: Object.entries(REACTION_EMOJIS).map(([type, emoji]) => ({
|
||||
type,
|
||||
@@ -180,15 +180,12 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Get reactions for chat timeline (non-aggregated, for display in chat)
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { mediaId: string };
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/:mediaId/chat',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { mediaId: string };
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const mediaId = parseInt(request.params.mediaId, 10);
|
||||
const limit = parseInt(request.query.limit || '500', 10);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth, requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@@ -15,12 +15,12 @@ export async function shortsRoutes(fastify: FastifyInstance) {
|
||||
* GET /api/shorts - Public shorts feed
|
||||
* Returns published short videos (<=60s) for the TikTok-style feed
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: ShortsQuery }>(
|
||||
'/shorts',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: ShortsQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20'), 50);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const sort = request.query.sort || 'recent';
|
||||
|
||||
@@ -43,17 +43,6 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract metadata fields from form data
|
||||
const metadataFields = data.fields as Record<string, { value: string }>;
|
||||
const metadata = {
|
||||
title: metadataFields.title?.value,
|
||||
producer: metadataFields.producer?.value,
|
||||
creator: metadataFields.creator?.value,
|
||||
};
|
||||
|
||||
// Validate metadata
|
||||
const validatedMetadata = UploadMetadataSchema.parse(metadata);
|
||||
|
||||
// Generate unique filename
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const inboxDir = '/media/local/inbox';
|
||||
@@ -64,10 +53,22 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const filePath = join(inboxDir, filename);
|
||||
tempFilePath = filePath;
|
||||
|
||||
// Stream file to disk
|
||||
// Stream file to disk (must consume file stream BEFORE reading metadata fields,
|
||||
// because Fastify multipart/busboy only makes fields available after the file stream ends)
|
||||
logger.info(`Uploading video to ${filePath}`);
|
||||
await pipeline(data.file, createWriteStream(filePath));
|
||||
|
||||
// Extract metadata fields from form data (now available after file stream consumed)
|
||||
const metadataFields = data.fields as Record<string, { value: string }>;
|
||||
const metadata = {
|
||||
title: metadataFields.title?.value,
|
||||
producer: metadataFields.producer?.value,
|
||||
creator: metadataFields.creator?.value,
|
||||
};
|
||||
|
||||
// Validate metadata
|
||||
const validatedMetadata = UploadMetadataSchema.parse(metadata);
|
||||
|
||||
// Validate video file
|
||||
logger.info(`Validating video file: ${filePath}`);
|
||||
const isValid = await validateVideoFile(filePath);
|
||||
@@ -96,6 +97,7 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
fileSize: videoMetadata.fileSize,
|
||||
directoryType: 'inbox',
|
||||
isValid: true,
|
||||
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
|
||||
producer: validatedMetadata.producer || null,
|
||||
creator: validatedMetadata.creator || null,
|
||||
},
|
||||
@@ -220,6 +222,7 @@ async function uploadBatch(request: FastifyRequest, reply: FastifyReply) {
|
||||
fileSize: videoMetadata.fileSize,
|
||||
directoryType: 'inbox',
|
||||
isValid: true,
|
||||
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
@@ -40,7 +40,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/me/stats',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
// Upsert UserStats (create with defaults if missing)
|
||||
@@ -80,13 +80,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* GET /me/watch-history
|
||||
* Paginated recent VideoView records with video info
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: WatchHistoryQuery }>(
|
||||
'/me/watch-history',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Querystring: WatchHistoryQuery }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const offset = parseInt(request.query.offset || '0', 10);
|
||||
@@ -127,7 +124,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.post(
|
||||
'/me/stats/recalculate',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
// Aggregate from raw tables
|
||||
@@ -258,7 +255,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/me/settings',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const [privacy, user] = await Promise.all([
|
||||
@@ -281,13 +278,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/settings
|
||||
* Update PrivacySettings boolean toggles
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: UpdateSettingsBody }>(
|
||||
'/me/settings',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: UpdateSettingsBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const body = request.body;
|
||||
|
||||
@@ -324,13 +318,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/profile
|
||||
* Update user name (email is read-only)
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: UpdateProfileBody }>(
|
||||
'/me/profile',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: UpdateProfileBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { name } = request.body;
|
||||
|
||||
@@ -352,13 +343,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/password
|
||||
* Change password (requires current password verification)
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: ChangePasswordBody }>(
|
||||
'/me/password',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: ChangePasswordBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { currentPassword, newPassword } = request.body;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||
@@ -25,12 +25,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* PATCH /videos/:id
|
||||
* Update video metadata
|
||||
*/
|
||||
fastify.patch(
|
||||
fastify.patch<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const parseResult = UpdateVideoSchema.safeParse(request.body);
|
||||
|
||||
@@ -82,12 +82,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/duplicate
|
||||
* Duplicate a video with a new title
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{ Params: { id: string }; Body: { title?: string } }>(
|
||||
'/:id/duplicate',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string }; Body: { title?: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { title } = request.body || {};
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
width: originalVideo.width,
|
||||
height: originalVideo.height,
|
||||
thumbnailPath: originalVideo.thumbnailPath,
|
||||
tags: originalVideo.tags,
|
||||
tags: originalVideo.tags as any,
|
||||
directoryType: originalVideo.directoryType,
|
||||
category: originalVideo.category,
|
||||
uploaderId: originalVideo.uploaderId,
|
||||
@@ -147,25 +147,22 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* Replace video file while keeping metadata and URL
|
||||
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
newPath: string;
|
||||
newFilename: string;
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
}>(
|
||||
'/:id/replace',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
newPath: string;
|
||||
newFilename: string;
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
|
||||
|
||||
@@ -216,18 +213,15 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/analytics
|
||||
* Get detailed analytics for a video
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { id: string };
|
||||
Querystring: { startDate?: string; endDate?: string };
|
||||
}>(
|
||||
'/:id/analytics',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { startDate?: string; endDate?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { startDate, endDate } = request.query;
|
||||
|
||||
@@ -253,12 +247,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/reset-analytics
|
||||
* Reset all analytics for a video
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/:id/reset-analytics',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
try {
|
||||
@@ -279,12 +273,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/preview-link
|
||||
* Generate a temporary preview link with expiring JWT token
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id/preview-link',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
try {
|
||||
@@ -307,7 +301,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
{ expiresIn: `${expiryHours}h` }
|
||||
);
|
||||
|
||||
const previewUrl = `${env.MEDIA_API_URL}/api/videos/${videoId}/preview?token=${token}`;
|
||||
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}/api/videos/${videoId}/preview?token=${token}`;
|
||||
|
||||
logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
|
||||
|
||||
@@ -327,17 +321,14 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/analytics/top
|
||||
* Get top performing videos
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Querystring: { metric?: 'views' | 'watchTime'; limit?: string };
|
||||
}>(
|
||||
'/analytics/top',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Querystring: { metric?: 'views' | 'watchTime'; limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const metric = request.query.metric || 'views';
|
||||
const limit = parseInt(request.query.limit || '10');
|
||||
|
||||
@@ -364,7 +355,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const [totalVideos, totalViews, totalWatchTime, avgCompletionRate] = await Promise.all([
|
||||
prisma.video.count(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { videoScheduleQueueService } from '../../../services/video-schedule-queue.service';
|
||||
@@ -21,18 +21,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/schedule-publish
|
||||
* Schedule a video to be published at a specific time
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: { publishAt: string; timezone?: string };
|
||||
}>(
|
||||
'/:id/schedule-publish',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: { publishAt: string; timezone?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { publishAt, timezone } = request.body;
|
||||
|
||||
@@ -95,18 +92,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/schedule-unpublish
|
||||
* Schedule a video to be unpublished at a specific time
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: { unpublishAt: string; timezone?: string };
|
||||
}>(
|
||||
'/:id/schedule-unpublish',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: { unpublishAt: string; timezone?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { unpublishAt, timezone } = request.body;
|
||||
|
||||
@@ -169,17 +163,14 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* DELETE /videos/:id/schedule/:action
|
||||
* Cancel a scheduled publish or unpublish
|
||||
*/
|
||||
fastify.delete(
|
||||
fastify.delete<{
|
||||
Params: { id: string; action: string };
|
||||
}>(
|
||||
'/:id/schedule/:action',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string; action: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const action = request.params.action as 'publish' | 'unpublish';
|
||||
|
||||
@@ -209,17 +200,14 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/schedules/upcoming
|
||||
* Get all upcoming scheduled publish/unpublish operations
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/schedules/upcoming',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '50');
|
||||
|
||||
try {
|
||||
@@ -240,18 +228,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/schedule-history
|
||||
* Get schedule history for a specific video
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { id: string };
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/:id/schedule-history',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const limit = parseInt(request.query.limit || '10');
|
||||
|
||||
@@ -278,7 +263,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const stats = await videoScheduleQueueService.getStats();
|
||||
|
||||
@@ -299,7 +284,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await videoScheduleQueueService.pause();
|
||||
|
||||
@@ -323,7 +308,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await videoScheduleQueueService.resume();
|
||||
|
||||
@@ -347,7 +332,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const cleaned = await videoScheduleQueueService.cleanup();
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
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';
|
||||
|
||||
// Rate limiting: 100 requests per minute per IP for tracking
|
||||
const trackingRateLimit = {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
};
|
||||
|
||||
// Validation schemas
|
||||
const recordViewSchema = z.object({
|
||||
videoId: z.number(),
|
||||
@@ -34,20 +28,14 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Record a new video view (called when video starts loading)
|
||||
* Public endpoint - no auth required, but optionally uses auth if available
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: { videoId: number; referer?: string };
|
||||
}>(
|
||||
'/view',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
config: {
|
||||
rateLimit: trackingRateLimit,
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: { videoId: number; referer?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { videoId, referer } = request.body;
|
||||
|
||||
// Validate input
|
||||
@@ -94,24 +82,16 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Record a video event (play, pause, seek, complete)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
videoId: number;
|
||||
viewId?: number;
|
||||
eventType: 'play' | 'pause' | 'seek' | 'complete';
|
||||
timestamp: number;
|
||||
};
|
||||
}>(
|
||||
'/event',
|
||||
{
|
||||
config: {
|
||||
rateLimit: trackingRateLimit,
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
videoId: number;
|
||||
viewId?: number;
|
||||
eventType: 'play' | 'pause' | 'seek' | 'complete';
|
||||
timestamp: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { videoId, viewId, eventType, timestamp } = request.body;
|
||||
|
||||
// Validate input
|
||||
@@ -147,25 +127,14 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Update watch time for a view (called every 10 seconds during playback)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
viewId: number;
|
||||
watchTimeSeconds: number;
|
||||
};
|
||||
}>(
|
||||
'/heartbeat',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 200, // Higher limit for heartbeats (every 10s)
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
viewId: number;
|
||||
watchTimeSeconds: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { viewId, watchTimeSeconds } = request.body;
|
||||
|
||||
// Validate input
|
||||
@@ -196,27 +165,16 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Batch record multiple events (useful for reducing requests)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
events: Array<{
|
||||
type: 'view' | 'event' | 'heartbeat';
|
||||
data: any;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
'/batch',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 50,
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
events: Array<{
|
||||
type: 'view' | 'event' | 'heartbeat';
|
||||
data: any;
|
||||
}>;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { events } = request.body;
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
@@ -266,7 +224,7 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* GET /track/health
|
||||
* Health check for tracking endpoints
|
||||
*/
|
||||
fastify.get('/health', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'video-tracking',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate, requireAdminRole, optionalAuth } from '../middleware/auth';
|
||||
import { z } from 'zod';
|
||||
@@ -20,12 +20,12 @@ interface ListVideosQuery {
|
||||
|
||||
export async function videosRoutes(fastify: FastifyInstance) {
|
||||
// List videos (admin only)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: ListVideosQuery }>(
|
||||
'/',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: ListVideosQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '50');
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@@ -91,7 +91,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
// Map videos to include thumbnailUrl
|
||||
const videosWithThumbnails = videos.map((video) => ({
|
||||
...video,
|
||||
duration: video.durationSeconds, // Add duration alias for frontend
|
||||
duration: video.durationSeconds ?? 0, // Add duration alias for frontend
|
||||
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
|
||||
}));
|
||||
|
||||
@@ -105,12 +105,12 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get single video (admin only for now)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findUnique({
|
||||
@@ -124,7 +124,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return {
|
||||
video: {
|
||||
...video,
|
||||
duration: video.durationSeconds,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
|
||||
},
|
||||
};
|
||||
@@ -137,7 +137,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const videos = await prisma.video.findMany({
|
||||
where: {
|
||||
producer: { not: null },
|
||||
@@ -153,7 +153,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Health check for videos routes
|
||||
fastify.get('/health', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
// Test database connection by counting videos
|
||||
const count = await prisma.video.count();
|
||||
|
||||
@@ -387,8 +387,8 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
const thumbnailPath = await ThumbnailService.generateThumbnail({
|
||||
videoPath: video.path,
|
||||
videoId: video.id,
|
||||
duration: video.durationSeconds,
|
||||
orientation: video.orientation,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
orientation: video.orientation ?? '',
|
||||
});
|
||||
|
||||
// Update video with thumbnail path
|
||||
@@ -444,8 +444,8 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
const thumbnailPath = await ThumbnailService.generateThumbnail({
|
||||
videoPath: video.path,
|
||||
videoId: video.id,
|
||||
duration: video.durationSeconds,
|
||||
orientation: video.orientation,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
orientation: video.orientation ?? '',
|
||||
});
|
||||
|
||||
// Update video with thumbnail path
|
||||
|
||||
@@ -137,13 +137,14 @@ export class VideoAnalyticsService {
|
||||
where: { videoId },
|
||||
}),
|
||||
// Unique viewers (registered users only)
|
||||
prisma.videoView.count({
|
||||
prisma.videoView.findMany({
|
||||
where: {
|
||||
videoId,
|
||||
userId: { not: null },
|
||||
},
|
||||
distinct: ['userId'],
|
||||
}),
|
||||
select: { userId: true },
|
||||
}).then(views => views.length),
|
||||
// Total watch time
|
||||
prisma.videoView.aggregate({
|
||||
where: { videoId },
|
||||
|
||||
@@ -209,6 +209,13 @@ async function exportToMkDocs(opts: ExportOptions): Promise<string> {
|
||||
content = wrapInMaterialOverride(html, css);
|
||||
}
|
||||
|
||||
// Rewrite relative media/gallery URLs to absolute for MkDocs context
|
||||
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
content = content.replace(/src="\/media\/public\//g, `src="${adminUrl}/media/public/`);
|
||||
content = content.replace(/href="\/gallery\/watch\//g, `href="${adminUrl}/gallery/watch/`);
|
||||
content = content.replace(/href="\/gallery\?expanded=/g, `href="${adminUrl}/gallery?expanded=`);
|
||||
content = content.replace(/src="http:\/\/localhost:4100\//g, `src="${adminUrl.replace(/:\d+$/, ':4100')}/`);
|
||||
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
logger.info(`Exported landing page to MkDocs: ${mkdocsPath} (${editorMode}/${exportMode})`);
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@ router.post('/setup', pangolinSetupLimiter, async (req: Request, res: Response)
|
||||
logger.warn(`Created ${fullDomain} but failed to set as publicly accessible:`, updateErr);
|
||||
}
|
||||
|
||||
created.push({ subdomain: def.subdomain || '(root)', name: def.name, resourceId: resource.resourceId || resource.siteResourceId });
|
||||
created.push({ subdomain: def.subdomain || '(root)', name: def.name, siteResourceId: resource.resourceId || resource.siteResourceId });
|
||||
logger.info(`Created HTTP proxy resource: ${fullDomain}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -632,7 +632,7 @@ router.post('/sync', pangolinSetupLimiter, async (_req: Request, res: Response)
|
||||
|
||||
if (resourceNeedsUpdate(existingResource, desired)) {
|
||||
try {
|
||||
await pangolinClient.updateResource(existingResource.resourceId, {
|
||||
await pangolinClient.updateResource(existingResource.resourceId!, {
|
||||
name: desired.name,
|
||||
ssl: desired.ssl,
|
||||
proxyPort: desired.proxyPort,
|
||||
@@ -825,7 +825,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
logger.info(`Target payload: ${JSON.stringify(createTargetPayload)}`);
|
||||
try {
|
||||
logger.info('Attempting target creation with standard endpoint...');
|
||||
const createdTarget = await pangolinClient.createTarget(resourceId, createTargetPayload);
|
||||
const createdTarget = await pangolinClient.createTarget(resourceId!, createTargetPayload);
|
||||
logger.info(`✅ Step 2 Success: Target created (standard endpoint)`);
|
||||
(results.steps as any).push({
|
||||
step: 2,
|
||||
@@ -900,7 +900,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
// --- Step 3: Verify resource was created ---
|
||||
logger.info('Step 3: Verifying resource exists');
|
||||
try {
|
||||
const verifiedResource = await pangolinClient.getResource(resourceId);
|
||||
const verifiedResource = await pangolinClient.getResource(resourceId!);
|
||||
logger.info(`✅ Step 3 Success: Resource verified`);
|
||||
(results.steps as any).push({
|
||||
step: 3,
|
||||
@@ -922,7 +922,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
// --- Step 4: Cleanup ---
|
||||
logger.info('Step 4: Cleanup (deleting test resource)');
|
||||
try {
|
||||
await pangolinClient.deleteResource(resourceId);
|
||||
await pangolinClient.deleteResource(resourceId!);
|
||||
logger.info(`✅ Step 4 Success: Resource deleted`);
|
||||
(results.steps as any).push({
|
||||
step: 4,
|
||||
|
||||
@@ -17,13 +17,14 @@ router.get(
|
||||
'/status',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline] = await Promise.all([
|
||||
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline] = await Promise.all([
|
||||
isServiceOnline(env.NOCODB_URL),
|
||||
isServiceOnline(env.N8N_URL),
|
||||
isServiceOnline(env.GITEA_URL),
|
||||
isServiceOnline(env.MAILHOG_URL),
|
||||
isServiceOnline(env.MINI_QR_URL),
|
||||
isServiceOnline(env.EXCALIDRAW_URL),
|
||||
isServiceOnline(env.HOMEPAGE_URL),
|
||||
]);
|
||||
|
||||
// Update Prometheus gauges
|
||||
@@ -33,6 +34,7 @@ router.get(
|
||||
setServiceUp('mailhog', mailhogOnline);
|
||||
setServiceUp('miniqr', miniqrOnline);
|
||||
setServiceUp('excalidraw', excalidrawOnline);
|
||||
setServiceUp('homepage', homepageOnline);
|
||||
|
||||
res.json({
|
||||
nocodb: { online: nocodbOnline, url: env.NOCODB_URL },
|
||||
@@ -41,6 +43,7 @@ router.get(
|
||||
mailhog: { online: mailhogOnline, url: env.MAILHOG_URL },
|
||||
miniqr: { online: miniqrOnline, url: env.MINI_QR_URL },
|
||||
excalidraw: { online: excalidrawOnline, url: env.EXCALIDRAW_URL },
|
||||
homepage: { online: homepageOnline, url: env.HOMEPAGE_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to check services status', err);
|
||||
@@ -82,6 +85,9 @@ router.get(
|
||||
// Alertmanager (alert routing)
|
||||
alertmanagerPort: 9093,
|
||||
alertmanagerSubdomain: 'alertmanager',
|
||||
// Homepage (service dashboard)
|
||||
homepagePort: env.HOMEPAGE_EMBED_PORT,
|
||||
homepageSubdomain: 'home',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -187,18 +187,46 @@ class EmailService {
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
streamUrl: string;
|
||||
durationSeconds?: number;
|
||||
quality?: string;
|
||||
viewCount?: number;
|
||||
} | null> {
|
||||
try {
|
||||
const mediaApiUrl = env.MEDIA_API_PUBLIC_URL || 'http://media-api:4100';
|
||||
const response = await fetch(`${mediaApiUrl}/api/videos/${videoId}/metadata`);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
return await response.json() as {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
streamUrl: string;
|
||||
durationSeconds?: number;
|
||||
quality?: string;
|
||||
viewCount?: number;
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch video ${videoId} metadata:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private formatViewCount(count: number): string {
|
||||
if (!count || count <= 0) return '0 views';
|
||||
if (count === 1) return '1 view';
|
||||
if (count < 1000) return `${count} views`;
|
||||
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K views`;
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M views`;
|
||||
}
|
||||
|
||||
async processTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string>,
|
||||
@@ -213,23 +241,45 @@ class EmailService {
|
||||
const video = await this.getVideoMetadata(varDef.videoId);
|
||||
if (video) {
|
||||
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
const watchUrl = `${publicUrl}/gallery/watch/${video.id}`;
|
||||
const duration = this.formatDuration(video.durationSeconds || 0);
|
||||
const quality = video.quality ? this.escapeHtml(video.quality) : '';
|
||||
const views = this.formatViewCount(video.viewCount || 0);
|
||||
const metaLine = [duration, quality, views].filter(Boolean).join(' • ');
|
||||
const videoHtml = `
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; margin: 0 auto;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; margin: 16px auto; border-radius: 8px; overflow: hidden; background-color: #1b2838;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${publicUrl}/media/${video.id}" style="display: block; text-decoration: none;">
|
||||
<td style="padding: 0;">
|
||||
<a href="${watchUrl}" style="display: block; text-decoration: none;">
|
||||
<img src="${video.thumbnailUrl}"
|
||||
alt="${this.escapeHtml(video.title)}"
|
||||
style="width: 100%; max-width: 600px; height: auto; display: block; border-radius: 8px;" />
|
||||
width="480"
|
||||
style="width: 100%; max-width: 480px; height: auto; display: block;" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 12px; text-align: center;">
|
||||
<a href="${publicUrl}/media/${video.id}"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #0066cc; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">
|
||||
▶ Watch Video: ${this.escapeHtml(video.title)}
|
||||
</a>
|
||||
<td style="padding: 12px 16px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #ffffff; font-size: 15px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${this.escapeHtml(video.title)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 6px; color: #8899aa; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${metaLine}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 12px; text-align: center;">
|
||||
<a href="${watchUrl}"
|
||||
style="display: inline-block; padding: 10px 24px; background-color: #9d4edd; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
▶ Watch Video
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -281,7 +331,11 @@ class EmailService {
|
||||
const video = await this.getVideoMetadata(varDef.videoId);
|
||||
if (video) {
|
||||
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
const textLink = `Watch Video: ${video.title}\n${publicUrl}/media/${video.id}`;
|
||||
const duration = this.formatDuration(video.durationSeconds || 0);
|
||||
const quality = video.quality || '';
|
||||
const views = this.formatViewCount(video.viewCount || 0);
|
||||
const meta = [duration, quality, views].filter(Boolean).join(', ');
|
||||
const textLink = `Watch "${video.title}" (${meta})\n${publicUrl}/gallery/watch/${video.id}`;
|
||||
result = result.replace(
|
||||
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
||||
textLink
|
||||
@@ -416,16 +470,16 @@ class EmailService {
|
||||
|
||||
if (dbTemplate) {
|
||||
// Use database template
|
||||
html = this.processTemplate(dbTemplate.html, vars);
|
||||
text = this.processTemplate(dbTemplate.text, vars);
|
||||
html = await this.processTemplate(dbTemplate.html, vars);
|
||||
text = await this.processTemplate(dbTemplate.text, vars);
|
||||
subject = this.processSubject(dbTemplate.subject, vars);
|
||||
logger.debug('Using campaign email template from database');
|
||||
} else {
|
||||
// Fallback to filesystem
|
||||
const htmlTemplate = this.loadTemplate('campaign-email', 'html');
|
||||
const txtTemplate = this.loadTemplate('campaign-email', 'txt');
|
||||
html = this.processTemplate(htmlTemplate, vars);
|
||||
text = this.processTemplate(txtTemplate, vars);
|
||||
html = await this.processTemplate(htmlTemplate, vars);
|
||||
text = await this.processTemplate(txtTemplate, vars);
|
||||
subject = options.subject; // Use provided subject for filesystem fallback
|
||||
logger.warn('Using campaign email template from filesystem (fallback)');
|
||||
}
|
||||
@@ -725,16 +779,16 @@ class EmailService {
|
||||
|
||||
if (dbTemplate) {
|
||||
// Use database template
|
||||
html = this.processTemplate(dbTemplate.html, vars);
|
||||
text = this.processTemplate(dbTemplate.text, vars);
|
||||
html = await this.processTemplate(dbTemplate.html, vars);
|
||||
text = await this.processTemplate(dbTemplate.text, vars);
|
||||
subject = this.processSubject(dbTemplate.subject, vars);
|
||||
logger.debug('Using response verification template from database');
|
||||
} else {
|
||||
// Fallback to filesystem
|
||||
const htmlTemplate = this.loadTemplate('response-verification', 'html');
|
||||
const txtTemplate = this.loadTemplate('response-verification', 'txt');
|
||||
html = this.processTemplate(htmlTemplate, vars);
|
||||
text = this.processTemplate(txtTemplate, vars);
|
||||
html = await this.processTemplate(htmlTemplate, vars);
|
||||
text = await this.processTemplate(txtTemplate, vars);
|
||||
subject = `Verify Response — ${options.campaignTitle}`;
|
||||
logger.warn('Using response verification template from filesystem (fallback)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user