- Add 7 new RC notification types: campaign published, donations, subscriptions, SMS escalations, user approved, video published, ticketed events - Add CRM activity entries for subscription activated and email bounced - Migrate ticketed-events Gancio sync from inline calls to EventBus listener - Add meeting.created/deleted events from jitsi.routes.ts - Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum (Prisma migration) - Update calendar-sync listener to use proper source types instead of MANUAL - Total: 45 listener subscriptions across 6 modules, zero inline sync calls remaining Bunker Admin
348 lines
9.6 KiB
TypeScript
348 lines
9.6 KiB
TypeScript
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<boolean> {
|
|
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;
|