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:
2026-02-17 15:42:32 -07:00
parent 58dc1942ec
commit 99a6abab06
1511 changed files with 14551 additions and 1625410 deletions

View File

@@ -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('')

View File

@@ -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.)

View File

@@ -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' });

View File

@@ -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' });

View File

@@ -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' });

View File

@@ -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({

View File

@@ -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);

View File

@@ -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';

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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',

View File

@@ -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

View File

@@ -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 },

View File

@@ -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})`);

View File

@@ -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,

View File

@@ -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',
});
},
);

View File

@@ -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(' &bull; ');
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;">
&#9654; 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)');
}