import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint'; const router = Router(); function escapeIcs(text: string): string { return text .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\n/g, '\\n') .replace(/\r/g, ''); } // ICS lines must be folded at 75 octets (RFC 5545 §3.1) function fold(line: string): string { const MAX = 75; if (line.length <= MAX) return line; let out = ''; let pos = 0; while (pos < line.length) { if (pos === 0) { out += line.slice(0, MAX); pos = MAX; } else { out += '\r\n ' + line.slice(pos, pos + MAX - 1); pos += MAX - 1; } } return out; } function toIcsDate(d: Date): string { const p = (n: number) => String(n).padStart(2, '0'); return ( `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` + `T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z` ); } // Parse "HH:MM", "H:MM am/pm", "Hpm" etc. function parseLocalTime(t: string): { h: number; m: number } { const clean = t.trim(); const m24 = clean.match(/^(\d{1,2}):(\d{2})$/); if (m24) return { h: parseInt(m24[1]), m: parseInt(m24[2]) }; const mAp = clean.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); if (mAp) { let h = parseInt(mAp[1]); const m = mAp[2] ? parseInt(mAp[2]) : 0; if (mAp[3].toLowerCase() === 'pm' && h !== 12) h += 12; if (mAp[3].toLowerCase() === 'am' && h === 12) h = 0; return { h, m }; } return { h: 18, m: 0 }; } // Brussels is UTC+1 (CET) / UTC+2 (CEST). Use +1 as conservative default. const BRUSSELS_OFFSET_HOURS = 1; function parseEventDates( dateStr: string, timeStr: string ): { start: Date; end: Date } { const [year, month, day] = dateStr.split('-').map(Number); const parts = timeStr.split(/\s*[-–]\s*/); const { h: startH, m: startM } = parseLocalTime(parts[0]); // Convert local Brussels time to UTC const utcStartH = startH - BRUSSELS_OFFSET_HOURS; const start = new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0)); let end: Date; if (parts[1]) { const { h: endH, m: endM } = parseLocalTime(parts[1]); const utcEndH = endH - BRUSSELS_OFFSET_HOURS; end = new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0)); if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000); } else { end = new Date(start.getTime() + 2 * 60 * 60 * 1000); } return { start, end }; } router.get('/ics', async (_req: Request, res: Response) => { try { const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const cutoff = sevenDaysAgo.toISOString().slice(0, 10); const meetups = await prisma.meetup.findMany({ // Public subscription feed — never expose HIDDEN or unpublished meetups. where: { date: { gte: cutoff }, visibility: 'PUBLIC', status: 'PUBLISHED', }, orderBy: { date: 'asc' }, include: { organizer: true }, }); const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace( /\/$/, '' ); const now = new Date(); const lines: string[] = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Belgian Bitcoin Embassy//Events//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', fold('X-WR-CALNAME:Belgian Bitcoin Embassy Events'), fold('X-WR-CALDESC:Upcoming meetups and events by the Belgian Bitcoin Embassy'), 'X-WR-TIMEZONE:Europe/Brussels', ]; for (const meetup of meetups) { try { const { start, end } = parseEventDates(meetup.date, meetup.time); const eventUrl = meetup.link || `${siteUrl}/events/${meetup.id}`; lines.push('BEGIN:VEVENT'); lines.push(fold(`UID:${meetup.id}@belgianbitcoinembassy.org`)); lines.push(`DTSTAMP:${toIcsDate(now)}`); lines.push(`DTSTART:${toIcsDate(start)}`); lines.push(`DTEND:${toIcsDate(end)}`); lines.push(fold(`SUMMARY:${escapeIcs(meetup.title)}`)); if (meetup.description) { lines.push(fold(`DESCRIPTION:${escapeIcs(meetup.description)}`)); } if (meetup.location) { lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`)); } lines.push(fold(`URL:${eventUrl}`)); const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy'; lines.push( fold( `ORGANIZER;CN=${escapeIcs(orgName)}:mailto:info@belgianbitcoinembassy.org` ) ); // 15-minute reminder alarm lines.push('BEGIN:VALARM'); lines.push('TRIGGER:-PT15M'); lines.push('ACTION:DISPLAY'); lines.push(fold(`DESCRIPTION:Reminder: ${escapeIcs(meetup.title)}`)); lines.push('END:VALARM'); lines.push('END:VEVENT'); } catch { // Skip events with unparseable dates } } lines.push('END:VCALENDAR'); const icsBody = lines.join('\r\n') + '\r\n'; res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'inline; filename="bbe-events.ics"', 'Cache-Control': 'public, max-age=300', }); res.send(icsBody); } catch (err) { console.error('Calendar ICS error:', err); if (respondIfOrganizerMigrationNeeded(err, res)) return; res.status(500).json({ error: 'Internal server error' }); } }); export default router;