225 lines
6.6 KiB
TypeScript
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 };
|