import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { redis } from '../../config/redis'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { siteSettingsService } from '../settings/settings.service'; import { logger } from '../../utils/logger'; import { eventSubmissionRateLimit } from '../../middleware/rate-limit'; import { unifiedCalendarService } from './unified-calendar.service'; import { gancioClient } from '../../services/gancio.client'; const router = Router(); const CACHE_KEY = 'events:public-list'; const CACHE_TTL = 300; // 5 minutes export interface PublicEvent { id: number; title: string; description: string; placeName: string; placeAddress: string; startDatetime: string; endDatetime: string | null; tags: string[]; shiftId: string | null; } // GET /api/events/public?limit=20&upcoming=true router.get('/public', async (req: Request, res: Response, next: NextFunction) => { try { const settings = await siteSettingsService.getPublic(); if (!settings.enableEvents) { res.json([]); return; } const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); const upcoming = req.query.upcoming !== 'false'; // Check cache const cacheKey = `${CACHE_KEY}:${upcoming}:${limit}`; try { const cached = await redis.get(cacheKey); if (cached) { res.json(JSON.parse(cached)); return; } } catch { /* cache miss */ } // Fetch from Gancio public API (no auth needed) let rawEvents: Array<{ id: number; title: string; description: string; place_name: string; place_address: string; start_datetime: number; end_datetime?: number; tags: string[]; }>; try { const url = `${env.GANCIO_URL}/api/events`; const fetchRes = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (!fetchRes.ok) { res.json([]); return; } rawEvents = await fetchRes.json() as typeof rawEvents; } catch (err) { logger.debug('Failed to fetch events from Gancio:', err); res.json([]); return; } const nowUnix = Math.floor(Date.now() / 1000); let filtered = rawEvents; if (upcoming) { filtered = rawEvents.filter(e => e.start_datetime >= nowUnix); } filtered.sort((a, b) => a.start_datetime - b.start_datetime); filtered = filtered.slice(0, limit); // Batch-load matching shifts const gancioIds = filtered.map(e => e.id); const shifts = await prisma.shift.findMany({ where: { gancioEventId: { in: gancioIds } }, select: { id: true, gancioEventId: true }, }); const shiftMap = new Map(shifts.map(s => [s.gancioEventId!, s.id])); const events: PublicEvent[] = filtered.map(e => ({ id: e.id, title: e.title, description: (e.description || '').slice(0, 300), placeName: e.place_name || '', placeAddress: e.place_address || '', startDatetime: new Date(e.start_datetime * 1000).toISOString(), endDatetime: e.end_datetime ? new Date(e.end_datetime * 1000).toISOString() : null, tags: Array.isArray(e.tags) ? e.tags : [], shiftId: shiftMap.get(e.id) ?? null, })); // Cache try { await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(events)); } catch { /* non-critical */ } res.json(events); } catch (err) { next(err); } }); // --- Unified Calendar --- const calendarQuerySchema = z.object({ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), }).refine(data => { const start = new Date(data.startDate); const end = new Date(data.endDate); const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); return diffDays >= 0 && diffDays <= 90; }, { message: 'Date range must be 0-90 days' }); // GET /api/events/calendar?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD router.get('/calendar', async (req: Request, res: Response, next: NextFunction) => { try { const settings = await siteSettingsService.getPublic(); if (!settings.enableEvents) { res.json({ dates: {} }); return; } const parsed = calendarQuerySchema.safeParse(req.query); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid date range', code: 'VALIDATION_ERROR' }, }); return; } const result = await unifiedCalendarService.getCalendar( parsed.data.startDate, parsed.data.endDate, ); res.json(result); } catch (err) { next(err); } }); // --- Event Submission (Proxy to Gancio) --- const eventSubmitSchema = z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM'), endTime: z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM'), location: z.string().max(500).optional(), tags: z.array(z.string().max(50)).max(10).optional(), }); // POST /api/events/submit router.post('/submit', eventSubmissionRateLimit, async (req: Request, res: Response, next: NextFunction) => { try { const settings = await siteSettingsService.getPublic(); if (!settings.enableEvents) { res.status(403).json({ error: { message: 'Events are not enabled', code: 'EVENTS_DISABLED' }, }); return; } if (!gancioClient.enabled) { res.status(503).json({ error: { message: 'Event service is not configured', code: 'GANCIO_NOT_CONFIGURED' }, }); return; } const parsed = eventSubmitSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: { message: 'Invalid event data', code: 'VALIDATION_ERROR' }, }); return; } const { title, description, date, startTime, endTime, location, tags } = parsed.data; // Ensure 'community' tag is always included const eventTags = [...new Set([...(tags || []), 'community'])]; const gancioEventId = await gancioClient.createEvent({ title, description, location, date: new Date(date), startTime, endTime, tags: eventTags, }); if (gancioEventId === null) { res.status(502).json({ error: { message: 'Failed to create event in calendar service', code: 'GANCIO_ERROR' }, }); return; } // Bust calendar cache so the new event appears await unifiedCalendarService.bustCache(); res.status(201).json({ success: true, gancioEventId }); } catch (err) { next(err); } }); export { router as eventsListPublicRouter };