first commit
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
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({
|
||||
where: { date: { gte: cutoff } },
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
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}`));
|
||||
lines.push(
|
||||
'ORGANIZER;CN=Belgian Bitcoin Embassy: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);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user