import { Router, Request, Response, NextFunction } from 'express'; import crypto from 'crypto'; import { z } from 'zod'; import { authenticate } from '../../middleware/auth.middleware'; import { requireNonTemp } from '../../middleware/rbac.middleware'; import { env } from '../../config/env'; import { logger } from '../../utils/logger'; import { prisma } from '../../config/database'; import { siteSettingsService } from '../settings/settings.service'; import { isServiceOnline } from '../../utils/health-check'; import { generateSlug, generateModeratorToken } from './jitsi.utils'; import { eventBus } from '../../services/event-bus.service'; const router = Router(); /** Check if meet is enabled (DB setting wins, env var is fallback for first boot) */ async function isMeetEnabled(): Promise { try { const settings = await siteSettingsService.get(); return settings.enableMeet; } catch { return env.ENABLE_MEET === 'true'; } } const tokenSchema = z.object({ room: z.string().min(1).max(200), }); const createMeetingSchema = z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), startTime: z.string().datetime().optional(), endTime: z.string().datetime().optional(), }); // GET /api/jitsi/status — health check (any authenticated user) router.get( '/status', authenticate, async (_req: Request, res: Response, next: NextFunction) => { try { const enabled = await isMeetEnabled(); const online = enabled ? await isServiceOnline(env.JITSI_URL) : false; res.json({ online, enabled }); } catch (err) { logger.error('Jitsi status check failed:', err); next(err); } }, ); // GET /api/jitsi/config — return Jitsi URLs + enabled status router.get( '/config', authenticate, async (_req: Request, res: Response, _next: NextFunction) => { const enabled = await isMeetEnabled(); res.json({ enabled, embedPort: env.JITSI_EMBED_PORT, subdomain: 'meet', domain: env.DOMAIN, }); }, ); // POST /api/jitsi/token — generate JWT for a standalone meeting room router.post( '/token', authenticate, requireNonTemp, async (req: Request, res: Response, next: NextFunction) => { try { const enabled = await isMeetEnabled(); if (!enabled) { res.status(400).json({ error: 'Video meetings are not enabled' }); return; } if (!env.JITSI_APP_SECRET) { res.status(500).json({ error: 'Jitsi JWT secret is not configured' }); return; } const parsed = tokenSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid room name (1-200 characters required)' }); return; } const { room } = parsed.data; const user = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { id: true, email: true, name: true }, }); if (!user) { res.status(401).json({ error: 'User not found' }); return; } const token = generateModeratorToken(user, room); res.json({ token, room }); } catch (err) { logger.error('Jitsi token generation failed:', err); next(err); } }, ); // ============================================================================ // MEETING CRUD // ============================================================================ // GET /api/jitsi/meetings — list user's meetings (authenticated) router.get( '/meetings', authenticate, async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user!.id; const userRole = req.user!.role; const isAdmin = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(userRole); // Admins see all meetings, regular users see only their own const where = isAdmin ? {} : { createdByUserId: userId }; const meetings = await prisma.meeting.findMany({ where, orderBy: { createdAt: 'desc' }, }); res.json({ meetings }); } catch (err) { logger.error('List meetings failed:', err); next(err); } }, ); // POST /api/jitsi/meetings — create a meeting (authenticated, non-TEMP) router.post( '/meetings', authenticate, requireNonTemp, async (req: Request, res: Response, next: NextFunction) => { try { const enabled = await isMeetEnabled(); if (!enabled) { res.status(400).json({ error: 'Video meetings are not enabled' }); return; } const parsed = createMeetingSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid meeting data', details: parsed.error.flatten().fieldErrors }); return; } const { title, description, startTime, endTime } = parsed.data; const meeting = await prisma.meeting.create({ data: { slug: generateSlug(title), title, description: description || null, jitsiRoom: crypto.randomUUID(), createdByUserId: req.user!.id, startTime: startTime ? new Date(startTime) : null, endTime: endTime ? new Date(endTime) : null, }, }); eventBus.publish('meeting.created', { meetingId: meeting.id, title: meeting.title, scheduledAt: meeting.startTime?.toISOString() ?? new Date().toISOString(), jitsiRoomName: meeting.jitsiRoom, createdByUserId: meeting.createdByUserId, }); res.status(201).json(meeting); } catch (err) { logger.error('Create meeting failed:', err); next(err); } }, ); // GET /api/jitsi/meetings/:slug — get meeting details (public, no auth required) router.get( '/meetings/:slug', async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const meeting = await prisma.meeting.findUnique({ where: { slug }, }); if (!meeting) { res.status(404).json({ error: 'Meeting not found' }); return; } res.json(meeting); } catch (err) { logger.error('Get meeting failed:', err); next(err); } }, ); // DELETE /api/jitsi/meetings/:id — delete a meeting (owner or SUPER_ADMIN) router.delete( '/meetings/:id', authenticate, async (req: Request, res: Response, next: NextFunction) => { try { const meetingId = req.params.id as string; const meeting = await prisma.meeting.findUnique({ where: { id: meetingId }, }); if (!meeting) { res.status(404).json({ error: 'Meeting not found' }); return; } // Only the creator or a SUPER_ADMIN can delete if (meeting.createdByUserId !== req.user!.id && req.user!.role !== 'SUPER_ADMIN') { res.status(403).json({ error: 'Not authorized to delete this meeting' }); return; } await prisma.meeting.delete({ where: { id: meetingId } }); eventBus.publish('meeting.deleted', { meetingId: meeting.id, title: meeting.title, }); res.json({ success: true }); } catch (err) { logger.error('Delete meeting failed:', err); next(err); } }, ); // POST /api/jitsi/meetings/:slug/token — generate moderator JWT for a meeting (authenticated, non-TEMP) router.post( '/meetings/:slug/token', authenticate, requireNonTemp, async (req: Request, res: Response, next: NextFunction) => { try { const enabled = await isMeetEnabled(); if (!enabled) { res.status(400).json({ error: 'Video meetings are not enabled' }); return; } if (!env.JITSI_APP_SECRET) { res.status(500).json({ error: 'Jitsi JWT secret is not configured' }); return; } const slug = req.params.slug as string; const meeting = await prisma.meeting.findUnique({ where: { slug }, }); if (!meeting) { res.status(404).json({ error: 'Meeting not found' }); return; } if (!meeting.isActive) { res.status(400).json({ error: 'Meeting is no longer active' }); return; } const user = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { id: true, email: true, name: true }, }); if (!user) { res.status(401).json({ error: 'User not found' }); return; } const token = generateModeratorToken(user, meeting.jitsiRoom); res.json({ token, jitsiRoom: meeting.jitsiRoom, domain: `meet.${env.DOMAIN}` }); } catch (err) { logger.error('Meeting token generation failed:', err); next(err); } }, ); // GET /api/jitsi/meetings/:slug/join — public info for guest join page (no auth) router.get( '/meetings/:slug/join', async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const meeting = await prisma.meeting.findUnique({ where: { slug }, select: { title: true, description: true, jitsiRoom: true, isActive: true, startTime: true, endTime: true, }, }); if (!meeting) { res.status(404).json({ error: 'Meeting not found' }); return; } if (!meeting.isActive) { res.status(410).json({ error: 'This meeting has ended' }); return; } res.json({ title: meeting.title, description: meeting.description, jitsiRoom: meeting.jitsiRoom, domain: `meet.${env.DOMAIN}`, startTime: meeting.startTime, endTime: meeting.endTime, }); } catch (err) { logger.error('Meeting join info failed:', err); next(err); } }, ); export const jitsiRouter = router;