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:
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user