Add ICS calendar file generation and 'Add to Calendar' button

- Added generateICSContent() function to create RFC-compliant ICS calendar files
- Added /api/events/:slug/calendar.ics endpoint to serve downloadable calendar files
- Updated RSVP confirmation emails to include styled 'Add to Calendar!' button
- ICS files include event title, description, location, date/time, and unique identifiers
- Calendar files are automatically named with event slug for easy identification
- Button links directly to ICS download endpoint for seamless calendar integration
- Supports all major calendar applications (Outlook, Google Calendar, Apple Calendar, etc.)
This commit is contained in:
Ryderjj89
2025-05-26 18:10:53 -04:00
parent 65d02964dc
commit d7ed4d1e85
2 changed files with 107 additions and 1 deletions

View File

@@ -1,5 +1,72 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
// Function to generate ICS calendar content
export function generateICSContent(eventData: {
title: string;
description: string;
location: string;
date: string; // ISO date string
slug: string;
}): string {
const { title, description, location, date, slug } = eventData;
// Convert date to ICS format (YYYYMMDDTHHMMSSZ)
const eventDate = new Date(date);
const startDate = eventDate.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Set end time to 2 hours after start time (default duration)
const endDate = new Date(eventDate.getTime() + 2 * 60 * 60 * 1000);
const endDateFormatted = endDate.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Generate unique ID for the event
const uid = `${slug}-${Date.now()}@rsvp-manager`;
// Current timestamp for DTSTAMP
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Clean description for ICS format (remove HTML, escape special chars)
const cleanDescription = description
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\n/g, '\\n') // Escape newlines
.replace(/,/g, '\\,') // Escape commas
.replace(/;/g, '\\;') // Escape semicolons
.replace(/\\/g, '\\\\'); // Escape backslashes
// Clean location for ICS format
const cleanLocation = location
.replace(/,/g, '\\,')
.replace(/;/g, '\\;')
.replace(/\\/g, '\\\\');
// Clean title for ICS format
const cleanTitle = title
.replace(/,/g, '\\,')
.replace(/;/g, '\\;')
.replace(/\\/g, '\\\\');
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//RSVP Manager//Event Calendar//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${now}`,
`DTSTART:${startDate}`,
`DTEND:${endDateFormatted}`,
`SUMMARY:${cleanTitle}`,
`DESCRIPTION:${cleanDescription}`,
`LOCATION:${cleanLocation}`,
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
return icsContent;
}
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST, host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT), port: Number(process.env.EMAIL_PORT),
@@ -98,11 +165,21 @@ export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) {
const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line
// Generate calendar download link
const baseUrl = process.env.FRONTEND_BASE_URL || '';
const calendarLink = `${baseUrl}/api/events/${eventSlug}/calendar.ics`;
const html = ` const html = `
<p>Hello ${name},</p> <p>Hello ${name},</p>
<p>You have successfully RSVP'd for the event "${eventTitle}".</p> <p>You have successfully RSVP'd for the event "${eventTitle}".</p>
<p>You can edit your RSVP at any time by clicking the link below:</p> <p>You can edit your RSVP at any time by clicking the link below:</p>
<p><a href="${editLink}">${editLink}</a></p> <p><a href="${editLink}">${editLink}</a></p>
<p style="margin: 20px 0;">
<a href="${calendarLink}"
style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
📅 Add to Calendar!
</a>
</p>
<p>Please save this email if you think you might need to edit your submission later.</p> <p>Please save this email if you think you might need to edit your submission later.</p>
<p>Thank you!</p> <p>Thank you!</p>
`; `;

View File

@@ -6,7 +6,7 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import multer from 'multer'; import multer from 'multer';
import fs from 'fs'; import fs from 'fs';
import { sendRSVPEmail, sendRSVPEditLinkEmail, sendEventConclusionEmail } from './email'; // Import the new email function import { sendRSVPEmail, sendRSVPEditLinkEmail, sendEventConclusionEmail, generateICSContent } from './email'; // Import the new email function and ICS generator
import cron from 'node-cron'; // Import node-cron for scheduling import cron from 'node-cron'; // Import node-cron for scheduling
dotenv.config(); dotenv.config();
@@ -1049,6 +1049,35 @@ const scheduledTask = cron.schedule('0 8 * * *', () => {
console.log(`Event conclusion email scheduled task scheduled for timezone ${process.env.TZ || 'UTC'} at 8:00 AM.`); console.log(`Event conclusion email scheduled task scheduled for timezone ${process.env.TZ || 'UTC'} at 8:00 AM.`);
// ICS Calendar file endpoint
app.get('/api/events/:slug/calendar.ics', async (req: Request, res: Response) => {
try {
const { slug } = req.params;
const event = await db.get('SELECT * FROM events WHERE slug = ?', [slug]);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Generate ICS content
const icsContent = generateICSContent({
title: event.title,
description: event.description || '',
location: event.location || '',
date: event.date,
slug: event.slug
});
// Set appropriate headers for ICS file download
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.slug}-calendar.ics"`);
res.send(icsContent);
} catch (error) {
console.error('Error generating ICS file:', error);
res.status(500).json({ error: 'Failed to generate calendar file' });
}
});
// Handle client-side routing // Handle client-side routing
app.get('*', (req: Request, res: Response) => { app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '../frontend/build/index.html')); res.sendFile(path.join(__dirname, '../frontend/build/index.html'));