210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
|
|
// server/api/events/calendar-feed.get.ts
|
||
|
|
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||
|
|
|
||
|
|
export default defineEventHandler(async (event) => {
|
||
|
|
try {
|
||
|
|
const query = getQuery(event);
|
||
|
|
const { user_id, user_role, format } = query;
|
||
|
|
|
||
|
|
if (!user_id) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: 'User ID is required'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const eventsClient = createNocoDBEventsClient();
|
||
|
|
|
||
|
|
// Get events for the user (next 6 months)
|
||
|
|
const now = new Date();
|
||
|
|
const sixMonthsLater = new Date();
|
||
|
|
sixMonthsLater.setMonth(now.getMonth() + 6);
|
||
|
|
|
||
|
|
const filters = {
|
||
|
|
start_date: now.toISOString(),
|
||
|
|
end_date: sixMonthsLater.toISOString(),
|
||
|
|
user_role: (user_role as string) || 'user',
|
||
|
|
status: 'active'
|
||
|
|
};
|
||
|
|
|
||
|
|
const response = await eventsClient.findUserEvents(user_id as string, filters);
|
||
|
|
const events = response.list || [];
|
||
|
|
|
||
|
|
// Generate iCal content
|
||
|
|
const icalContent = generateICalContent(events, {
|
||
|
|
calendarName: 'MonacoUSA Events',
|
||
|
|
timezone: 'Europe/Paris',
|
||
|
|
includeRSVPStatus: true
|
||
|
|
});
|
||
|
|
|
||
|
|
// Set appropriate headers for calendar subscription
|
||
|
|
setHeader(event, 'Content-Type', 'text/calendar; charset=utf-8');
|
||
|
|
setHeader(event, 'Content-Disposition', 'attachment; filename="monacousa-events.ics"');
|
||
|
|
setHeader(event, 'Cache-Control', 'no-cache, must-revalidate');
|
||
|
|
|
||
|
|
return icalContent;
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error generating calendar feed:', error);
|
||
|
|
throw createError({
|
||
|
|
statusCode: 500,
|
||
|
|
statusMessage: 'Failed to generate calendar feed'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate iCal content from events array
|
||
|
|
*/
|
||
|
|
function generateICalContent(events: any[], options: {
|
||
|
|
calendarName: string;
|
||
|
|
timezone: string;
|
||
|
|
includeRSVPStatus: boolean;
|
||
|
|
}): string {
|
||
|
|
const now = new Date();
|
||
|
|
const lines: string[] = [];
|
||
|
|
|
||
|
|
// iCal header
|
||
|
|
lines.push('BEGIN:VCALENDAR');
|
||
|
|
lines.push('VERSION:2.0');
|
||
|
|
lines.push('PRODID:-//MonacoUSA//Portal//EN');
|
||
|
|
lines.push(`X-WR-CALNAME:${options.calendarName}`);
|
||
|
|
lines.push(`X-WR-TIMEZONE:${options.timezone}`);
|
||
|
|
lines.push('X-WR-CALDESC:Events from MonacoUSA Portal');
|
||
|
|
lines.push('CALSCALE:GREGORIAN');
|
||
|
|
lines.push('METHOD:PUBLISH');
|
||
|
|
|
||
|
|
// Add timezone definition
|
||
|
|
lines.push('BEGIN:VTIMEZONE');
|
||
|
|
lines.push(`TZID:${options.timezone}`);
|
||
|
|
lines.push('BEGIN:STANDARD');
|
||
|
|
lines.push('DTSTART:19701025T030000');
|
||
|
|
lines.push('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU');
|
||
|
|
lines.push('TZNAME:CET');
|
||
|
|
lines.push('TZOFFSETFROM:+0200');
|
||
|
|
lines.push('TZOFFSETTO:+0100');
|
||
|
|
lines.push('END:STANDARD');
|
||
|
|
lines.push('BEGIN:DAYLIGHT');
|
||
|
|
lines.push('DTSTART:19700329T020000');
|
||
|
|
lines.push('RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU');
|
||
|
|
lines.push('TZNAME:CEST');
|
||
|
|
lines.push('TZOFFSETFROM:+0100');
|
||
|
|
lines.push('TZOFFSETTO:+0200');
|
||
|
|
lines.push('END:DAYLIGHT');
|
||
|
|
lines.push('END:VTIMEZONE');
|
||
|
|
|
||
|
|
// Process each event
|
||
|
|
events.forEach(eventItem => {
|
||
|
|
if (!eventItem.start_datetime || !eventItem.end_datetime) return;
|
||
|
|
|
||
|
|
const startDate = new Date(eventItem.start_datetime);
|
||
|
|
const endDate = new Date(eventItem.end_datetime);
|
||
|
|
const createdDate = eventItem.created_at ? new Date(eventItem.created_at) : now;
|
||
|
|
const modifiedDate = eventItem.updated_at ? new Date(eventItem.updated_at) : now;
|
||
|
|
|
||
|
|
lines.push('BEGIN:VEVENT');
|
||
|
|
lines.push(`UID:${eventItem.id}@monacousa.org`);
|
||
|
|
lines.push(`DTSTART;TZID=${options.timezone}:${formatICalDateTime(startDate)}`);
|
||
|
|
lines.push(`DTEND;TZID=${options.timezone}:${formatICalDateTime(endDate)}`);
|
||
|
|
lines.push(`DTSTAMP:${formatICalDateTime(now)}`);
|
||
|
|
lines.push(`CREATED:${formatICalDateTime(createdDate)}`);
|
||
|
|
lines.push(`LAST-MODIFIED:${formatICalDateTime(modifiedDate)}`);
|
||
|
|
lines.push(`SUMMARY:${escapeICalText(eventItem.title)}`);
|
||
|
|
|
||
|
|
// Add description with event details
|
||
|
|
let description = eventItem.description || '';
|
||
|
|
if (eventItem.is_paid === 'true') {
|
||
|
|
description += `\\n\\nEvent Fee: €${eventItem.cost_members || eventItem.cost_non_members || 'TBD'}`;
|
||
|
|
if (eventItem.cost_members && eventItem.cost_non_members) {
|
||
|
|
description += ` (Members: €${eventItem.cost_members}, Non-members: €${eventItem.cost_non_members})`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add RSVP status if available
|
||
|
|
if (options.includeRSVPStatus && eventItem.user_rsvp) {
|
||
|
|
const rsvpStatus = eventItem.user_rsvp.rsvp_status;
|
||
|
|
description += `\\n\\nRSVP Status: ${rsvpStatus.toUpperCase()}`;
|
||
|
|
|
||
|
|
if (rsvpStatus === 'confirmed' && eventItem.user_rsvp.payment_reference) {
|
||
|
|
description += `\\nPayment Reference: ${eventItem.user_rsvp.payment_reference}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (description) {
|
||
|
|
lines.push(`DESCRIPTION:${escapeICalText(description)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add location
|
||
|
|
if (eventItem.location) {
|
||
|
|
lines.push(`LOCATION:${escapeICalText(eventItem.location)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add categories based on event type
|
||
|
|
const eventTypeLabels = {
|
||
|
|
'meeting': 'Meeting',
|
||
|
|
'social': 'Social Event',
|
||
|
|
'fundraiser': 'Fundraiser',
|
||
|
|
'workshop': 'Workshop',
|
||
|
|
'board-only': 'Board Meeting'
|
||
|
|
};
|
||
|
|
const category = eventTypeLabels[eventItem.event_type as keyof typeof eventTypeLabels] || 'Event';
|
||
|
|
lines.push(`CATEGORIES:${category}`);
|
||
|
|
|
||
|
|
// Add status
|
||
|
|
let eventStatus = 'CONFIRMED';
|
||
|
|
if (eventItem.status === 'cancelled') {
|
||
|
|
eventStatus = 'CANCELLED';
|
||
|
|
} else if (eventItem.status === 'draft') {
|
||
|
|
eventStatus = 'TENTATIVE';
|
||
|
|
}
|
||
|
|
lines.push(`STATUS:${eventStatus}`);
|
||
|
|
|
||
|
|
// Add transparency for paid events
|
||
|
|
if (eventItem.is_paid === 'true') {
|
||
|
|
lines.push('TRANSP:OPAQUE');
|
||
|
|
} else {
|
||
|
|
lines.push('TRANSP:TRANSPARENT');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add URL (link back to portal)
|
||
|
|
lines.push(`URL:https://portal.monacousa.org/dashboard/events`);
|
||
|
|
|
||
|
|
lines.push('END:VEVENT');
|
||
|
|
});
|
||
|
|
|
||
|
|
// iCal footer
|
||
|
|
lines.push('END:VCALENDAR');
|
||
|
|
|
||
|
|
// Join with CRLF as per RFC 5545
|
||
|
|
return lines.join('\r\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format date for iCal (YYYYMMDDTHHMMSS format)
|
||
|
|
*/
|
||
|
|
function formatICalDateTime(date: Date): string {
|
||
|
|
const year = date.getFullYear();
|
||
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
|
|
const day = String(date.getDate()).padStart(2, '0');
|
||
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
||
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||
|
|
|
||
|
|
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Escape special characters for iCal text fields
|
||
|
|
*/
|
||
|
|
function escapeICalText(text: string): string {
|
||
|
|
if (!text) return '';
|
||
|
|
|
||
|
|
return text
|
||
|
|
.replace(/\\/g, '\\\\') // Escape backslashes
|
||
|
|
.replace(/;/g, '\\;') // Escape semicolons
|
||
|
|
.replace(/,/g, '\\,') // Escape commas
|
||
|
|
.replace(/\n/g, '\\n') // Escape newlines
|
||
|
|
.replace(/\r/g, '') // Remove carriage returns
|
||
|
|
.replace(/"/g, '\\"'); // Escape quotes
|
||
|
|
}
|