first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions
+163
View File
@@ -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;