changemaker.lite/api/src/modules/events/events-public.routes.ts

225 lines
6.6 KiB
TypeScript

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