bunker-admin 68434c51a6 Extend EventBus: RC notifications, CRM activity, Gancio migration, calendar source types
- 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
2026-03-31 10:04:44 -06:00

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;