From 47f6918b0293bb9e27aed8c27572e19573aa63dc Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 26 May 2025 12:46:25 -0400 Subject: [PATCH] Implement backend functionality for event conclusion emails --- backend/package.json | 6 +- backend/src/email.ts | 34 ++++ backend/src/index.ts | 421 ++++++++++++++++++++++++++++++------------- 3 files changed, 337 insertions(+), 124 deletions(-) diff --git a/backend/package.json b/backend/package.json index 031e5ab..70a3f41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,9 +16,11 @@ "sqlite3": "^5.1.6", "sqlite": "^4.1.2", "nodemailer": "^6.9.8", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3" }, "devDependencies": { + "@types/node-cron": "^3.0.11", "@types/express": "^4.17.17", "@types/node": "^20.4.5", "@types/cors": "^2.8.13", @@ -29,4 +31,4 @@ "nodemon": "^3.0.1", "ts-node": "^10.9.1" } -} \ No newline at end of file +} diff --git a/backend/src/email.ts b/backend/src/email.ts index 3b49334..914910e 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -117,3 +117,37 @@ export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) { html, }); } + +export interface EventConclusionEmailData { + eventTitle: string; + attendeeName: string; + message: string; + to: string; +} + +export async function sendEventConclusionEmail(data: EventConclusionEmailData) { + const { + eventTitle, + attendeeName, + message, + to, + } = data; + + const subject = `Thank You for Attending ${eventTitle}!`; // Subject for the conclusion email + + const html = ` +

Hello ${attendeeName},

+

${message}

+

Thank you for attending!

+ `; + + await transporter.sendMail({ + from: { + name: process.env.EMAIL_FROM_NAME || '', + address: process.env.EMAIL_FROM_ADDRESS || process.env.EMAIL_USER || '', + }, + to, + subject, + html, + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 96c2516..1d4e58d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,8 @@ import dotenv from 'dotenv'; import path from 'path'; import multer from 'multer'; import fs from 'fs'; -import { sendRSVPEmail, sendRSVPEditLinkEmail } from './email'; // Import the new email function +import { sendRSVPEmail, sendRSVPEditLinkEmail, sendEventConclusionEmail } from './email'; // Import the new email function +import cron from 'node-cron'; // Import node-cron for scheduling dotenv.config(); @@ -46,6 +47,10 @@ interface RSVP { guest_count: number; guest_names: string | null; items_bringing: string | null; + other_items: string | null; + edit_id: string; + send_event_conclusion_email: boolean; // Added field for event conclusion email opt-in + attendee_email: string | null; // Added field for attendee email created_at?: string; } @@ -53,7 +58,7 @@ async function connectToDatabase() { try { // Database file will be in the app directory const dbPath = path.join(__dirname, '../database.sqlite'); - + db = await open({ filename: dbPath, driver: sqlite3.Database @@ -107,24 +112,16 @@ const upload = multer({ app.get('/api/events', async (req: Request, res: Response) => { try { const rows = await db.all('SELECT * FROM events'); - - // Add the full path to wallpapers - const events = rows.map((event: { - id: number; - title: string; - description: string; - date: string; - location: string; - slug: string; - needed_items: string | null; - wallpaper: string | null; - created_at: string; - }) => ({ + + // Add the full path to wallpapers and parse JSON fields + const events = rows.map((event: any) => ({ ...event, wallpaper: event.wallpaper ? `/uploads/wallpapers/${event.wallpaper}` : null, - needed_items: event.needed_items ? JSON.parse(event.needed_items) : [] + needed_items: event.needed_items ? JSON.parse(event.needed_items) : [], + email_notifications_enabled: Boolean(event.email_notifications_enabled), + event_conclusion_email_enabled: Boolean(event.event_conclusion_email_enabled), })); - + res.json(events); } catch (error) { console.error('Error fetching events:', error); @@ -140,13 +137,17 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => { return res.status(404).json({ error: 'Event not found' }); } - // Parse needed_items from JSON string to array + // Parse needed_items from JSON string to array and boolean fields const event = rows[0]; try { event.needed_items = event.needed_items ? JSON.parse(event.needed_items) : []; + event.email_notifications_enabled = Boolean(event.email_notifications_enabled); + event.event_conclusion_email_enabled = Boolean(event.event_conclusion_email_enabled); } catch (e) { - console.error('Error parsing needed_items:', e); + console.error('Error parsing event JSON/boolean fields:', e); event.needed_items = []; + event.email_notifications_enabled = false; + event.event_conclusion_email_enabled = false; } // Add the full path to the wallpaper @@ -163,22 +164,24 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => { app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, res: Response) => { try { - const { - title, - description, - date, - location, - needed_items, - rsvp_cutoff_date, + const { + title, + description, + date, + location, + needed_items, + rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, - email_recipients + email_recipients, + event_conclusion_email_enabled, // Receive new field + event_conclusion_message // Receive new field } = req.body; const wallpaperPath = req.file ? `${req.file.filename}` : null; - + // Generate a slug from the title const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); - + // Ensure needed_items is properly formatted let parsedNeededItems: string[] = []; try { @@ -190,27 +193,45 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r } catch (e) { console.error('Error parsing needed_items:', e); } - + // Parse max_guests_per_rsvp to ensure it's a number const maxGuests = parseInt(max_guests_per_rsvp as string) || 0; - - // Parse email_notifications_enabled to ensure it's a boolean + + // Parse boolean fields const emailNotificationsEnabled = email_notifications_enabled === 'true' || email_notifications_enabled === true; - + const eventConclusionEmailEnabled = event_conclusion_email_enabled === 'true' || event_conclusion_email_enabled === true; + + const result = await db.run( - 'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, email_recipients) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date, maxGuests, emailNotificationsEnabled ? 1 : 0, email_recipients || ''] + 'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, email_recipients, event_conclusion_email_enabled, event_conclusion_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + title, + description, + date, + location, + slug, + JSON.stringify(parsedNeededItems), + wallpaperPath, + rsvp_cutoff_date, + maxGuests, + emailNotificationsEnabled ? 1 : 0, + email_recipients || '', + eventConclusionEmailEnabled ? 1 : 0, // Save new field + event_conclusion_message || '' // Save new field + ] ); - - res.status(201).json({ - ...result, + + res.status(201).json({ + ...result, slug, wallpaper: wallpaperPath ? `/uploads/wallpapers/${wallpaperPath}` : null, needed_items: parsedNeededItems, rsvp_cutoff_date, max_guests_per_rsvp: maxGuests, email_notifications_enabled: emailNotificationsEnabled, - email_recipients: email_recipients || '' + email_recipients: email_recipients || '', + event_conclusion_email_enabled: eventConclusionEmailEnabled, // Include in response + event_conclusion_message: event_conclusion_message || '' // Include in response }); } catch (error) { console.error('Error creating event:', error); @@ -227,17 +248,17 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r app.delete('/api/events/:slug', async (req: Request, res: Response) => { try { const { slug } = req.params; - + // First check if the event exists const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + // Delete the event (RSVPs will be automatically deleted due to ON DELETE CASCADE) await db.run('DELETE FROM events WHERE slug = ?', [slug]); - + res.status(204).send(); } catch (error) { console.error('Error deleting event:', error); @@ -250,32 +271,34 @@ app.get('/api/events/:slug/rsvps', async (req: Request, res: Response) => { try { const { slug } = req.params; const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + const eventId = eventRows[0].id; const rows = await db.all('SELECT * FROM rsvps WHERE event_id = ?', [eventId]); - - // Parse JSON arrays in each RSVP + + // Parse JSON arrays and boolean fields in each RSVP const parsedRows = rows.map((rsvp: RSVP) => { try { return { ...rsvp, items_bringing: rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : [], - guest_names: rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [] + guest_names: rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [], + send_event_conclusion_email: Boolean(rsvp.send_event_conclusion_email), // Parse new field }; } catch (e) { - console.error('Error parsing RSVP JSON fields:', e); + console.error('Error parsing RSVP JSON/boolean fields:', e); return { ...rsvp, items_bringing: [], - guest_names: [] + guest_names: [], + send_event_conclusion_email: false, // Default value on error }; } }); - + res.json(parsedRows); } catch (error) { console.error('Error fetching RSVPs:', error); @@ -286,32 +309,33 @@ app.get('/api/events/:slug/rsvps', async (req: Request, res: Response) => { app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { try { const { slug } = req.params; - const { - name, - attending, - bringing_guests, - guest_count, - guest_names, - items_bringing, + const { + name, + attending, + bringing_guests, + guest_count, + guest_names, + items_bringing, other_items, - send_email_confirmation, // New field for email opt-in - email_address // New field for recipient email + send_email_confirmation, // Existing field for RSVP confirmation email opt-in + email_address, // Existing field for recipient email + send_event_conclusion_email // Receive new field for conclusion email opt-in } = req.body; - + // Get the event with email notification settings const eventRows = await db.all('SELECT id, title, slug, email_notifications_enabled, email_recipients FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + const event = eventRows[0]; const eventId = event.id; const eventTitle = event.title; const eventSlug = event.slug; - const emailNotificationsEnabled = event.email_notifications_enabled; + const emailNotificationsEnabled = Boolean(event.email_notifications_enabled); const eventEmailRecipients = event.email_recipients; - + // Parse items_bringing if it's a string let parsedItemsBringing: string[] = []; try { @@ -327,15 +351,26 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { // Parse guest_names if it's a string let parsedGuestNames: string[] = []; try { - if (typeof guest_names === 'string') { + if (typeof guest_names === 'string' && guest_names.includes('[')) { + // If it's a JSON string array parsedGuestNames = JSON.parse(guest_names); + } else if (typeof guest_names === 'string') { + // If it's a comma-separated string + parsedGuestNames = guest_names.split(',').map(name => name.trim()).filter(name => name); } else if (Array.isArray(guest_names)) { - parsedGuestNames = guest_names; + // If it's already an array + parsedGuestNames = guest_names.filter(name => name && name.trim()); } } catch (e) { console.error('Error parsing guest_names:', e); + parsedGuestNames = []; } - + + // Parse new boolean field + const sendEventConclusionEmailBool = send_event_conclusion_email === 'true' || send_event_conclusion_email === true; + const attendeeEmail = email_address?.trim() || null; // Store attendee email + + // Generate a unique edit ID let editId = ''; let isUnique = false; @@ -348,25 +383,37 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { } const result = await db.run( - 'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, edit_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', editId] + 'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, edit_id, send_event_conclusion_email, attendee_email) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + eventId, + name, + attending, + bringing_guests, + guest_count, + JSON.stringify(parsedGuestNames), + JSON.stringify(parsedItemsBringing), + other_items || '', + editId, + sendEventConclusionEmailBool ? 1 : 0, // Save new field + attendeeEmail // Save new field + ] ); // Send email notifications to event recipients if enabled for this event if (emailNotificationsEnabled) { // Get recipients from event settings let recipients: string[] = []; - + // Use the event's email recipients if (eventEmailRecipients) { recipients = eventEmailRecipients.split(',').map((addr: string) => addr.trim()).filter(Boolean); } - + // If no recipients are set for the event, use the sender email as a fallback if (recipients.length === 0 && process.env.EMAIL_USER) { recipients = [process.env.EMAIL_USER]; } - + if (recipients.length > 0) { try { for (const to of recipients) { @@ -395,23 +442,22 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { // Send email confirmation with edit link to the submitter if requested const sendEmailConfirmationBool = send_email_confirmation === 'true' || send_email_confirmation === true; - const submitterEmail = email_address?.trim(); - if (sendEmailConfirmationBool && submitterEmail && process.env.EMAIL_USER) { + if (sendEmailConfirmationBool && attendeeEmail && process.env.EMAIL_USER) { try { const editLink = `${process.env.FRONTEND_BASE_URL}/events/${eventSlug}/rsvp/edit/${editId}`; await sendRSVPEditLinkEmail({ eventTitle, eventSlug, name, - to: submitterEmail, + to: attendeeEmail, editLink, }); - console.log(`Sent RSVP edit link email to ${submitterEmail}`); + console.log(`Sent RSVP edit link email to ${attendeeEmail}`); } catch (emailErr) { console.error('Error sending RSVP edit link email:', emailErr); } - } else if (sendEmailConfirmationBool && !submitterEmail) { + } else if (sendEmailConfirmationBool && !attendeeEmail) { console.warn('Email confirmation requested but no email address provided. Skipping edit link email.'); } else if (sendEmailConfirmationBool && !process.env.EMAIL_USER) { console.warn('Email confirmation requested but EMAIL_USER environment variable is not set. Cannot send edit link email.'); @@ -432,6 +478,8 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { items_bringing: parsedItemsBringing, other_items: other_items || '', edit_id: editId, + send_event_conclusion_email: sendEventConclusionEmailBool, // Include in response + attendee_email: attendeeEmail, // Include in response created_at: new Date().toISOString() }); } catch (error) { @@ -450,14 +498,16 @@ app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { return res.status(404).json({ error: 'RSVP not found' }); } - // Parse arrays for response + // Parse arrays and boolean fields for response try { rsvp.items_bringing = rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : []; rsvp.guest_names = rsvp.guest_names ? JSON.parse(rsvp.guest_names) : []; + rsvp.send_event_conclusion_email = Boolean(rsvp.send_event_conclusion_email); // Parse new field } catch (e) { - console.error('Error parsing arrays in response:', e); + console.error('Error parsing RSVP JSON/boolean fields in response:', e); rsvp.items_bringing = []; rsvp.guest_names = []; + rsvp.send_event_conclusion_email = false; // Default value on error } res.json(rsvp); @@ -471,14 +521,14 @@ app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { try { const { slug, id } = req.params; - + // Verify the RSVP belongs to the correct event const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + const eventId = eventRows[0].id; await db.run('DELETE FROM rsvps WHERE id = ? AND event_id = ?', [id, eventId]); res.status(204).send(); @@ -492,7 +542,7 @@ app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { try { const { editId } = req.params; - const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body; + const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, send_event_conclusion_email, attendee_email } = req.body; // Receive new fields // Find the RSVP by edit_id const rsvp = await db.get('SELECT id, event_id FROM rsvps WHERE edit_id = ?', [editId]); @@ -534,10 +584,29 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { parsedGuestNames = []; } + // Parse new boolean field + const sendEventConclusionEmailBool = send_event_conclusion_email !== undefined ? + (send_event_conclusion_email === 'true' || send_event_conclusion_email === true) : + Boolean(rsvp.send_event_conclusion_email); // Use existing value if not provided + + const attendeeEmailToSave = attendee_email !== undefined ? attendee_email?.trim() || null : rsvp.attendee_email; + + // Update the RSVP await db.run( - 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ? WHERE id = ?', - [name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', rsvpId] + 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ?, send_event_conclusion_email = ?, attendee_email = ? WHERE id = ?', + [ + name ?? rsvp.name, + attending ?? rsvp.attending, + bringing_guests ?? rsvp.bringing_guests, + guest_count !== undefined ? guest_count : rsvp.guest_count, + JSON.stringify(parsedGuestNames), + JSON.stringify(parsedItemsBringing), + other_items === undefined ? rsvp.other_items : other_items || '', + sendEventConclusionEmailBool ? 1 : 0, // Update new field + attendeeEmailToSave, // Update new field + rsvpId + ] ); // Get the updated RSVP to verify and return @@ -547,14 +616,16 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { return res.status(404).json({ error: 'RSVP not found after update' }); } - // Parse arrays for response + // Parse arrays and boolean fields for response try { updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : []; updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : []; + updatedRsvp.send_event_conclusion_email = Boolean(updatedRsvp.send_event_conclusion_email); // Parse new field } catch (e) { console.error('Error parsing arrays in response:', e); updatedRsvp.items_bringing = []; updatedRsvp.guest_names = []; + updatedRsvp.send_event_conclusion_email = false; // Default value on error } res.json(updatedRsvp); @@ -569,17 +640,17 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { try { const { slug, id } = req.params; - const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body; - + const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, send_event_conclusion_email, attendee_email } = req.body; // Receive new fields + // Verify the RSVP belongs to the correct event const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + const eventId = eventRows[0].id; - + // Parse items_bringing if it's a string let parsedItemsBringing: string[] = []; try { @@ -610,27 +681,55 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { parsedGuestNames = []; } + // Get existing RSVP to check current values + const existingRsvp = await db.get('SELECT send_event_conclusion_email, attendee_email FROM rsvps WHERE id = ? AND event_id = ?', [id, eventId]); + if (!existingRsvp) { + return res.status(404).json({ error: 'RSVP not found for this event' }); + } + + // Parse new boolean field + const sendEventConclusionEmailBool = send_event_conclusion_email !== undefined ? + (send_event_conclusion_email === 'true' || send_event_conclusion_email === true) : + Boolean(existingRsvp.send_event_conclusion_email); // Use existing value if not provided + + const attendeeEmailToSave = attendee_email !== undefined ? attendee_email?.trim() || null : existingRsvp.attendee_email; + + // Update the RSVP await db.run( - 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ? WHERE id = ? AND event_id = ?', - [name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', id, eventId] + 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ?, send_event_conclusion_email = ?, attendee_email = ? WHERE id = ? AND event_id = ?', + [ + name ?? existingRsvp.name, // Use existing value if not provided + attending ?? existingRsvp.attending, // Use existing value if not provided + bringing_guests ?? existingRsvp.bringing_guests, // Use existing value if not provided + guest_count !== undefined ? guest_count : existingRsvp.guest_count, // Use existing value if not provided + JSON.stringify(parsedGuestNames), + JSON.stringify(parsedItemsBringing), + other_items === undefined ? existingRsvp.other_items : other_items || '', + sendEventConclusionEmailBool ? 1 : 0, // Update new field + attendeeEmailToSave, // Update new field + id, + eventId + ] ); // Get the updated RSVP to verify and return const updatedRsvp = await db.get('SELECT * FROM rsvps WHERE id = ? AND event_id = ?', [id, eventId]); - + if (!updatedRsvp) { return res.status(404).json({ error: 'RSVP not found after update' }); } - // Parse arrays for response + // Parse arrays and boolean fields for response try { updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : []; updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : []; + updatedRsvp.send_event_conclusion_email = Boolean(updatedRsvp.send_event_conclusion_email); // Parse new field } catch (e) { console.error('Error parsing arrays in response:', e); updatedRsvp.items_bringing = []; updatedRsvp.guest_names = []; + updatedRsvp.send_event_conclusion_email = false; // Default value on error } res.json(updatedRsvp); @@ -644,25 +743,27 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterRequest, res: Response) => { try { const { slug } = req.params; - const { - title, - description, - date, - location, - needed_items, - rsvp_cutoff_date, + const { + title, + description, + date, + location, + needed_items, + rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, - email_recipients + email_recipients, + event_conclusion_email_enabled, // Receive new field + event_conclusion_message // Receive new field } = req.body; - + // Verify the event exists const eventRows = await db.all('SELECT * FROM events WHERE slug = ?', [slug]); - + if (eventRows.length === 0) { return res.status(404).json({ error: 'Event not found' }); } - + // Ensure needed_items is properly formatted let parsedNeededItems: string[] = []; try { @@ -676,20 +777,29 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque } // Parse max_guests_per_rsvp to ensure it's a number - const maxGuests = max_guests_per_rsvp !== undefined ? - (parseInt(max_guests_per_rsvp as string) || 0) : + const maxGuests = max_guests_per_rsvp !== undefined ? + (parseInt(max_guests_per_rsvp as string) || 0) : eventRows[0].max_guests_per_rsvp || 0; - - // Parse email_notifications_enabled to ensure it's a boolean + + // Parse boolean fields const emailNotificationsEnabled = email_notifications_enabled !== undefined ? (email_notifications_enabled === 'true' || email_notifications_enabled === true) : - eventRows[0].email_notifications_enabled; - - // Get email recipients + Boolean(eventRows[0].email_notifications_enabled); + + const eventConclusionEmailEnabled = event_conclusion_email_enabled !== undefined ? + (event_conclusion_email_enabled === 'true' || event_conclusion_email_enabled === true) : + Boolean(eventRows[0].event_conclusion_email_enabled); // Use existing value if not provided + + // Get email recipients and conclusion message const emailRecipients = email_recipients !== undefined ? email_recipients : eventRows[0].email_recipients || ''; + const eventConclusionMessage = event_conclusion_message !== undefined ? + event_conclusion_message : + eventRows[0].event_conclusion_message || ''; // Use existing value if not provided + + // Handle wallpaper update let wallpaperPath = eventRows[0].wallpaper; if (req.file) { @@ -704,10 +814,10 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque } wallpaperPath = req.file.filename; } - + // Update the event await db.run( - 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ? WHERE slug = ?', + 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ?, event_conclusion_email_enabled = ?, event_conclusion_message = ? WHERE slug = ?', [ title ?? eventRows[0].title, description === undefined ? eventRows[0].description : description, @@ -719,26 +829,32 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque maxGuests, emailNotificationsEnabled ? 1 : 0, emailRecipients, + eventConclusionEmailEnabled ? 1 : 0, // Update new field + eventConclusionMessage, // Update new field slug ] ); // Get the updated event const updatedEvent = await db.get('SELECT * FROM events WHERE slug = ?', [slug]); - - // Add the full path to the wallpaper + + // Add the full path to the wallpaper and parse JSON/boolean fields if (updatedEvent.wallpaper) { updatedEvent.wallpaper = `/uploads/wallpapers/${updatedEvent.wallpaper}`; } - - // Parse needed_items for response + try { updatedEvent.needed_items = updatedEvent.needed_items ? JSON.parse(updatedEvent.needed_items) : []; + updatedEvent.email_notifications_enabled = Boolean(updatedEvent.email_notifications_enabled); + updatedEvent.event_conclusion_email_enabled = Boolean(updatedEvent.event_conclusion_email_enabled); } catch (e) { - console.error('Error parsing needed_items in response:', e); + console.error('Error parsing updated event JSON/boolean fields:', e); updatedEvent.needed_items = []; + updatedEvent.email_notifications_enabled = false; + updatedEvent.event_conclusion_email_enabled = false; } - + + res.json(updatedEvent); } catch (error) { console.error('Error updating event:', error); @@ -770,6 +886,8 @@ async function initializeDatabase() { max_guests_per_rsvp INTEGER DEFAULT 0, email_notifications_enabled BOOLEAN DEFAULT 0, email_recipients TEXT, + event_conclusion_email_enabled BOOLEAN DEFAULT 0, -- Added event conclusion email toggle + event_conclusion_message TEXT, -- Added event conclusion message field created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); @@ -787,6 +905,8 @@ async function initializeDatabase() { items_bringing TEXT, other_items TEXT, edit_id TEXT UNIQUE, -- Add a column for the unique edit ID + send_event_conclusion_email BOOLEAN DEFAULT 0, -- Added field for event conclusion email opt-in + attendee_email TEXT, -- Added field for attendee email created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE ) @@ -798,6 +918,61 @@ async function initializeDatabase() { } } +// Function to send event conclusion emails +async function sendConclusionEmails() { + console.log('Running scheduled task to send event conclusion emails...'); + try { + // Calculate yesterday's date in the format stored in the database (assuming YYYY-MM-DD) + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayString = yesterday.toISOString().split('T')[0]; + + // Find events that ended yesterday and have conclusion emails enabled + const events = await db.all( + 'SELECT id, title, event_conclusion_message FROM events WHERE date LIKE ? AND event_conclusion_email_enabled = 1', + [`${yesterdayString}%`] // Match any time on yesterday's date + ); + + for (const event of events) { + console.log(`Processing event "${event.title}" for conclusion email.`); + // Find RSVPs for this event where conclusion email is opted in and email is provided + const rsvps = await db.all( + 'SELECT name, attendee_email FROM rsvps WHERE event_id = ? AND send_event_conclusion_email = 1 AND attendee_email IS NOT NULL AND attendee_email != ""', + [event.id] + ); + + if (rsvps.length > 0) { + console.log(`Found ${rsvps.length} attendees opted in for conclusion email for event "${event.title}".`); + for (const rsvp of rsvps) { + try { + await sendEventConclusionEmail({ + eventTitle: event.title, + attendeeName: rsvp.name, + message: event.event_conclusion_message, + to: rsvp.attendee_email, + }); + console.log(`Sent conclusion email to ${rsvp.attendee_email} for event "${event.title}".`); + } catch (emailErr) { + console.error(`Error sending conclusion email to ${rsvp.attendee_email} for event "${event.title}":`, emailErr); + } + } + } else { + console.log(`No attendees opted in for conclusion email for event "${event.title}".`); + } + } + console.log('Finished running scheduled task.'); + } catch (error) { + console.error('Error in scheduled task to send event conclusion emails:', error); + } +} + + +// Schedule the task to run daily (e.g., at 1:00 AM) +cron.schedule('0 1 * * *', () => { + sendConclusionEmails(); +}); + + // Handle client-side routing app.get('*', (req: Request, res: Response) => { res.sendFile(path.join(__dirname, '../frontend/build/index.html')); @@ -807,4 +982,6 @@ app.get('*', (req: Request, res: Response) => { app.listen(port, async () => { console.log(`Server running on port ${port}`); await connectToDatabase(); + // Optionally run the task on startup for testing + // sendConclusionEmails(); });