From 8918a8acd7c41185f09f876d564502c02d31f502 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Sun, 4 May 2025 18:27:14 -0400 Subject: [PATCH 01/32] feat: add toggle pill to hide closed events in EventList --- frontend/src/components/EventList.tsx | 135 +++++++++++++++----------- 1 file changed, 76 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/EventList.tsx b/frontend/src/components/EventList.tsx index bb358ca..aa441fb 100644 --- a/frontend/src/components/EventList.tsx +++ b/frontend/src/components/EventList.tsx @@ -11,6 +11,8 @@ import { Container, Chip, Stack, + Switch, + FormControlLabel, } from '@mui/material'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import VisibilityIcon from '@mui/icons-material/Visibility'; @@ -30,6 +32,7 @@ interface Event { const EventList: React.FC = () => { const [events, setEvents] = useState([]); const navigate = useNavigate(); + const [hideClosed, setHideClosed] = useState(false); useEffect(() => { fetchEvents(); @@ -84,81 +87,95 @@ const EventList: React.FC = () => { > Create Event + + setHideClosed(checked)} + color="primary" + /> + } + label="Hide Closed Events" + /> + - {events.map((event) => ( - - - - - - {event.title} - - - - {event.description && ( + {events + .filter(event => !hideClosed || isEventOpen(event)) + .map((event) => ( + + + + + + {event.title} + + + + {event.description && ( + + Info: {event.description} + + )} - Info: {event.description} + Date: {new Date(event.date).toLocaleString()} - )} - - Date: {new Date(event.date).toLocaleString()} - - - Location: {event.location} - - {event.rsvp_cutoff_date && ( - RSVP cut-off date: {new Date(event.rsvp_cutoff_date).toLocaleString()} + Location: {event.location} - )} - - - - {isEventOpen(event) && ( + {event.rsvp_cutoff_date && ( + + RSVP cut-off date: {new Date(event.rsvp_cutoff_date).toLocaleString()} + + )} + + + + {isEventOpen(event) && ( + + )} - )} + - - - - - - ))} + + + + ))} From c2b42176019b47e7b147a9fe434bb7a8cc6b29d0 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 08:26:27 -0400 Subject: [PATCH 02/32] feat: send RSVP confirmation emails using environment-based SMTP config; split EMAIL_FROM into EMAIL_FROM_NAME and EMAIL_FROM_ADDRESS --- backend/package.json | 3 ++- backend/src/email.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++ backend/src/index.ts | 24 +++++++++++++++++ docker-compose.yml | 7 +++++ 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 backend/src/email.ts diff --git a/backend/package.json b/backend/package.json index 2f4e899..e33543c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "sqlite3": "^5.1.6", - "sqlite": "^4.1.2" + "sqlite": "^4.1.2", + "nodemailer": "^6.9.8" }, "devDependencies": { "@types/express": "^4.17.17", diff --git a/backend/src/email.ts b/backend/src/email.ts new file mode 100644 index 0000000..2648c71 --- /dev/null +++ b/backend/src/email.ts @@ -0,0 +1,63 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: Number(process.env.EMAIL_PORT), + secure: process.env.EMAIL_SECURE === 'true', // true for 465, false for other ports + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +export interface RSVPEmailData { + eventTitle: string; + name: string; + attending: string; + bringingGuests: string; + guestCount: number; + guestNames: string[]; + itemsBringing: string[]; + otherItems: string; + to: string; +} + +export async function sendRSVPEmail(data: RSVPEmailData) { + const { + eventTitle, + name, + attending, + bringingGuests, + guestCount, + guestNames, + itemsBringing, + otherItems, + to, + } = data; + + const subject = `RSVP Confirmation for ${eventTitle}`; + const guestList = guestNames.length ? guestNames.join(', ') : 'None'; + const itemsList = itemsBringing.length ? itemsBringing.join(', ') : 'None'; + const otherItemsList = otherItems ? otherItems : 'None'; + + const html = ` +

RSVP Confirmation

+

Event: ${eventTitle}

+

Name: ${name}

+

Attending: ${attending}

+

Bringing Guests: ${bringingGuests} (${guestCount})

+

Guest Names: ${guestList}

+

Items Bringing (from needed list): ${itemsList}

+

Other Items: ${otherItemsList}

+ `; + + await transporter.sendMail({ + from: { + name: process.env.EMAIL_FROM_NAME || '', + address: process.env.EMAIL_FROM_ADDRESS || process.env.EMAIL_USER || '', + }, + to, + subject, + html, + }); +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 8ea0d2e..7993c5d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,7 @@ import dotenv from 'dotenv'; import path from 'path'; import multer from 'multer'; import fs from 'fs'; +import { sendRSVPEmail } from './email'; dotenv.config(); @@ -294,6 +295,29 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { [eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || ''] ); + // Fetch event title for the email + const eventInfo = await db.get('SELECT title FROM events WHERE id = ?', [eventId]); + const eventTitle = eventInfo ? eventInfo.title : slug; + + // Send RSVP confirmation email (if email provided) + if (req.body.email) { + try { + await sendRSVPEmail({ + eventTitle, + name, + attending, + bringingGuests: bringing_guests, + guestCount: guest_count, + guestNames: parsedGuestNames, + itemsBringing: parsedItemsBringing, + otherItems: other_items || '', + to: req.body.email, + }); + } catch (emailErr) { + console.error('Error sending RSVP email:', emailErr); + } + } + // Return the complete RSVP data including the parsed arrays res.status(201).json({ id: result.lastID, diff --git a/docker-compose.yml b/docker-compose.yml index f600924..222d0c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,13 @@ services: - uploads:/app/uploads environment: - NODE_ENV=production + - EMAIL_HOST=smtp.example.com + - EMAIL_PORT=587 + - EMAIL_USER=your@email.com + - EMAIL_PASS=yourpassword + - EMAIL_FROM_NAME=RSVP Manager + - EMAIL_FROM_ADDRESS=your@email.com + - EMAIL_SECURE=false restart: unless-stopped volumes: From 7364e12c86bfa144ffff6d919b0e97b595f443bb Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 08:31:29 -0400 Subject: [PATCH 03/32] fix: add @types/nodemailer and include event links in RSVP email content --- backend/package.json | 1 + backend/src/email.ts | 9 +++++++++ backend/src/index.ts | 6 ++++-- docker-compose.yml | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/package.json b/backend/package.json index e33543c..9f63381 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "@types/node": "^20.4.5", "@types/cors": "^2.8.13", "@types/sqlite3": "^3.1.8", + "@types/nodemailer": "^6.4.12", "typescript": "^5.1.6", "nodemon": "^3.0.1", "ts-node": "^10.9.1" diff --git a/backend/src/email.ts b/backend/src/email.ts index 2648c71..48445b8 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -12,6 +12,7 @@ const transporter = nodemailer.createTransport({ export interface RSVPEmailData { eventTitle: string; + eventSlug: string; name: string; attending: string; bringingGuests: string; @@ -25,6 +26,7 @@ export interface RSVPEmailData { export async function sendRSVPEmail(data: RSVPEmailData) { const { eventTitle, + eventSlug, name, attending, bringingGuests, @@ -40,6 +42,11 @@ export async function sendRSVPEmail(data: RSVPEmailData) { const itemsList = itemsBringing.length ? itemsBringing.join(', ') : 'None'; const otherItemsList = otherItems ? otherItems : 'None'; + // Assume the frontend is served at the same host + const baseUrl = process.env.FRONTEND_BASE_URL || ''; + const manageRsvpsUrl = `${baseUrl}/events/${eventSlug}/manage-rsvps`; + const viewRsvpsUrl = `${baseUrl}/events/${eventSlug}/rsvps`; + const html = `

RSVP Confirmation

Event: ${eventTitle}

@@ -49,6 +56,8 @@ export async function sendRSVPEmail(data: RSVPEmailData) {

Guest Names: ${guestList}

Items Bringing (from needed list): ${itemsList}

Other Items: ${otherItemsList}

+

Manage RSVPs for this event

+

View all RSVPs for this event

`; await transporter.sendMail({ diff --git a/backend/src/index.ts b/backend/src/index.ts index 7993c5d..164ceb5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -295,15 +295,17 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { [eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || ''] ); - // Fetch event title for the email - const eventInfo = await db.get('SELECT title FROM events WHERE id = ?', [eventId]); + // Fetch event title and slug for the email + const eventInfo = await db.get('SELECT title, slug FROM events WHERE id = ?', [eventId]); const eventTitle = eventInfo ? eventInfo.title : slug; + const eventSlug = eventInfo ? eventInfo.slug : slug; // Send RSVP confirmation email (if email provided) if (req.body.email) { try { await sendRSVPEmail({ eventTitle, + eventSlug, name, attending, bringingGuests: bringing_guests, diff --git a/docker-compose.yml b/docker-compose.yml index 222d0c8..f1ef5ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - EMAIL_FROM_NAME=RSVP Manager - EMAIL_FROM_ADDRESS=your@email.com - EMAIL_SECURE=false + - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped volumes: From c56843dd1dce152f9ccf03c3f402445d3002dabf Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 08:37:05 -0400 Subject: [PATCH 04/32] chore: add email and frontend URL environment variables to docker-compose-build.yml for build parity --- docker-compose-build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker-compose-build.yml b/docker-compose-build.yml index 8dfbf84..9ebacac 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -11,6 +11,14 @@ services: - uploads:/app/uploads environment: - NODE_ENV=production + - EMAIL_HOST=smtp.example.com + - EMAIL_PORT=587 + - EMAIL_USER=your@email.com + - EMAIL_PASS=yourpassword + - EMAIL_FROM_NAME=RSVP Manager + - EMAIL_FROM_ADDRESS=your@email.com + - EMAIL_SECURE=false + - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped volumes: From 246a16f110ae00e988a310c8040fffb07655f482 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 08:56:16 -0400 Subject: [PATCH 05/32] fix: always send RSVP notification emails to admin (EMAIL_USER) instead of RSVP submitter --- backend/src/index.ts | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 164ceb5..319fc68 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -300,24 +300,22 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { const eventTitle = eventInfo ? eventInfo.title : slug; const eventSlug = eventInfo ? eventInfo.slug : slug; - // Send RSVP confirmation email (if email provided) - if (req.body.email) { - try { - await sendRSVPEmail({ - eventTitle, - eventSlug, - name, - attending, - bringingGuests: bringing_guests, - guestCount: guest_count, - guestNames: parsedGuestNames, - itemsBringing: parsedItemsBringing, - otherItems: other_items || '', - to: req.body.email, - }); - } catch (emailErr) { - console.error('Error sending RSVP email:', emailErr); - } + // Send RSVP confirmation email to admin + try { + await sendRSVPEmail({ + eventTitle, + eventSlug, + name, + attending, + bringingGuests: bringing_guests, + guestCount: guest_count, + guestNames: parsedGuestNames, + itemsBringing: parsedItemsBringing, + otherItems: other_items || '', + to: process.env.EMAIL_USER, + }); + } catch (emailErr) { + console.error('Error sending RSVP email:', emailErr); } // Return the complete RSVP data including the parsed arrays From 05b7b6741bbe077e3dcce0766ea10f6d1fa7f55c Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 09:01:16 -0400 Subject: [PATCH 06/32] fix: make RSVP email sending optional; skip if EMAIL_USER is not set --- backend/src/index.ts | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 319fc68..ba8bf0a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -300,22 +300,25 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { const eventTitle = eventInfo ? eventInfo.title : slug; const eventSlug = eventInfo ? eventInfo.slug : slug; - // Send RSVP confirmation email to admin - try { - await sendRSVPEmail({ - eventTitle, - eventSlug, - name, - attending, - bringingGuests: bringing_guests, - guestCount: guest_count, - guestNames: parsedGuestNames, - itemsBringing: parsedItemsBringing, - otherItems: other_items || '', - to: process.env.EMAIL_USER, - }); - } catch (emailErr) { - console.error('Error sending RSVP email:', emailErr); + // Optionally send RSVP confirmation email to admin if EMAIL_USER is set + const adminEmail = process.env.EMAIL_USER; + if (adminEmail) { + try { + await sendRSVPEmail({ + eventTitle, + eventSlug, + name, + attending, + bringingGuests: bringing_guests, + guestCount: guest_count, + guestNames: parsedGuestNames, + itemsBringing: parsedItemsBringing, + otherItems: other_items || '', + to: adminEmail, + }); + } catch (emailErr) { + console.error('Error sending RSVP email:', emailErr); + } } // Return the complete RSVP data including the parsed arrays From 708814f083c79e1567e5ee10fd3efe6cdfde846b Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 09:09:02 -0400 Subject: [PATCH 07/32] feat: support EMAIL_RECIPIENTS for RSVP notifications; send to all listed, fallback to EMAIL_USER, log if none set --- backend/src/index.ts | 39 ++++++++++++++++++++++++--------------- docker-compose.yml | 1 + 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index ba8bf0a..739ceb1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -300,25 +300,34 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { const eventTitle = eventInfo ? eventInfo.title : slug; const eventSlug = eventInfo ? eventInfo.slug : slug; - // Optionally send RSVP confirmation email to admin if EMAIL_USER is set - const adminEmail = process.env.EMAIL_USER; - if (adminEmail) { + // Optionally send RSVP confirmation email to recipients + let recipients: string[] = []; + if (process.env.EMAIL_RECIPIENTS) { + recipients = process.env.EMAIL_RECIPIENTS.split(',').map(addr => addr.trim()).filter(Boolean); + } else if (process.env.EMAIL_USER) { + recipients = [process.env.EMAIL_USER]; + } + if (recipients.length > 0) { try { - await sendRSVPEmail({ - eventTitle, - eventSlug, - name, - attending, - bringingGuests: bringing_guests, - guestCount: guest_count, - guestNames: parsedGuestNames, - itemsBringing: parsedItemsBringing, - otherItems: other_items || '', - to: adminEmail, - }); + for (const to of recipients) { + await sendRSVPEmail({ + eventTitle, + eventSlug, + name, + attending, + bringingGuests: bringing_guests, + guestCount: guest_count, + guestNames: parsedGuestNames, + itemsBringing: parsedItemsBringing, + otherItems: other_items || '', + to, + }); + } } catch (emailErr) { console.error('Error sending RSVP email:', emailErr); } + } else { + console.warn('No email recipients set. Skipping RSVP email notification.'); } // Return the complete RSVP data including the parsed arrays diff --git a/docker-compose.yml b/docker-compose.yml index f1ef5ff..47f8c7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - EMAIL_FROM_ADDRESS=your@email.com - EMAIL_SECURE=false - FRONTEND_BASE_URL=https://your-frontend-domain.com + - EMAIL_RECIPIENTS=admin1@email.com,admin2@email.com restart: unless-stopped volumes: From 68c991419ae6b4f54c563799b1944354bf527eaa Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 09:11:46 -0400 Subject: [PATCH 08/32] chore: update docker compose files for email and environment adjustments --- docker-compose-build.yml | 1 + docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose-build.yml b/docker-compose-build.yml index 9ebacac..c23e8a7 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -17,6 +17,7 @@ services: - EMAIL_PASS=yourpassword - EMAIL_FROM_NAME=RSVP Manager - EMAIL_FROM_ADDRESS=your@email.com + - EMAIL_RECIPIENTS=admin1@email.com,admin2@email.com - EMAIL_SECURE=false - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 47f8c7a..88c3d61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,8 @@ services: - EMAIL_FROM_NAME=RSVP Manager - EMAIL_FROM_ADDRESS=your@email.com - EMAIL_SECURE=false - - FRONTEND_BASE_URL=https://your-frontend-domain.com - EMAIL_RECIPIENTS=admin1@email.com,admin2@email.com + - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped volumes: From 866bdbebc2def81e4378d7d5bdf0ad6e7b30cdd8 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 09:25:55 -0400 Subject: [PATCH 09/32] chore: enable Nodemailer debug and logger for detailed SMTP troubleshooting --- backend/src/email.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/email.ts b/backend/src/email.ts index 48445b8..4760339 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -8,6 +8,8 @@ const transporter = nodemailer.createTransport({ user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, }, + logger: true, + debug: true, }); export interface RSVPEmailData { From 908b0f21ef6339a00984de8000def51cb7d2db04 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 09:56:35 -0400 Subject: [PATCH 10/32] fix: install backend dependencies in /app for production so nodemailer and others are available at runtime --- Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e37797..d3859fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,11 +36,9 @@ RUN mkdir -p /app/uploads/wallpapers && \ chmod 755 /app/uploads && \ chmod 644 /app/database.sqlite -# Copy package files and install dependencies -COPY package*.json ./ -COPY backend/package*.json ./backend/ +# Copy backend package files and install dependencies +COPY backend/package*.json ./ RUN npm install --production -RUN cd backend && npm install --production # Copy built files from builder stage COPY --from=builder /app/backend/dist ./dist From dc69dea804abb272e31c0d57aca639f5023bb950 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 10:02:39 -0400 Subject: [PATCH 11/32] fix: move multer to dependencies so it is installed in production --- backend/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 9f63381..031e5ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,8 @@ "dotenv": "^16.3.1", "sqlite3": "^5.1.6", "sqlite": "^4.1.2", - "nodemailer": "^6.9.8" + "nodemailer": "^6.9.8", + "multer": "^1.4.5-lts.1" }, "devDependencies": { "@types/express": "^4.17.17", @@ -23,6 +24,7 @@ "@types/cors": "^2.8.13", "@types/sqlite3": "^3.1.8", "@types/nodemailer": "^6.4.12", + "@types/multer": "^1.4.7", "typescript": "^5.1.6", "nodemon": "^3.0.1", "ts-node": "^10.9.1" From d3cdad21b47f067204a24507ac8acf739ea440c6 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 10:10:17 -0400 Subject: [PATCH 12/32] chore: disable Nodemailer debug logging and fix RSVP email links to use /view/events/:slug and /admin/events/:slug --- backend/src/email.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/src/email.ts b/backend/src/email.ts index 4760339..c16fbe0 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -7,9 +7,7 @@ const transporter = nodemailer.createTransport({ auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, - }, - logger: true, - debug: true, + } }); export interface RSVPEmailData { @@ -46,8 +44,8 @@ export async function sendRSVPEmail(data: RSVPEmailData) { // Assume the frontend is served at the same host const baseUrl = process.env.FRONTEND_BASE_URL || ''; - const manageRsvpsUrl = `${baseUrl}/events/${eventSlug}/manage-rsvps`; - const viewRsvpsUrl = `${baseUrl}/events/${eventSlug}/rsvps`; + const manageRsvpsUrl = `${baseUrl}/admin/events/${eventSlug}`; + const viewRsvpsUrl = `${baseUrl}/view/events/${eventSlug}`; const html = `

RSVP Confirmation

From d9bb1074b82622bc38a948d2f9e4da766fa01bfd Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 10:26:08 -0400 Subject: [PATCH 13/32] fix: capitalize 'Yes'/'No' for Attending and Bringing Guests in RSVP email --- backend/src/email.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/email.ts b/backend/src/email.ts index c16fbe0..10a1c46 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -37,10 +37,18 @@ export async function sendRSVPEmail(data: RSVPEmailData) { to, } = data; + // Capitalize attending and bringingGuests values + function capitalizeYesNo(value: string) { + if (typeof value !== 'string') return value; + return value.toLowerCase() === 'yes' ? 'Yes' : value.toLowerCase() === 'no' ? 'No' : value; + } + const subject = `RSVP Confirmation for ${eventTitle}`; const guestList = guestNames.length ? guestNames.join(', ') : 'None'; const itemsList = itemsBringing.length ? itemsBringing.join(', ') : 'None'; const otherItemsList = otherItems ? otherItems : 'None'; + const attendingDisplay = capitalizeYesNo(attending); + const bringingGuestsDisplay = capitalizeYesNo(bringingGuests); // Assume the frontend is served at the same host const baseUrl = process.env.FRONTEND_BASE_URL || ''; @@ -51,8 +59,8 @@ export async function sendRSVPEmail(data: RSVPEmailData) {

RSVP Confirmation

Event: ${eventTitle}

Name: ${name}

-

Attending: ${attending}

-

Bringing Guests: ${bringingGuests} (${guestCount})

+

Attending: ${attendingDisplay}

+

Bringing Guests: ${bringingGuestsDisplay} (${guestCount})

Guest Names: ${guestList}

Items Bringing (from needed list): ${itemsList}

Other Items: ${otherItemsList}

From ed92822913f5e8847a97bb1a1cf74c3333b9f087 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Mon, 5 May 2025 10:28:54 -0400 Subject: [PATCH 14/32] chore: change email label to 'Items Claimed' instead of 'Items Bringing (from needed list)' --- backend/src/email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/email.ts b/backend/src/email.ts index 10a1c46..0e9bce6 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -62,7 +62,7 @@ export async function sendRSVPEmail(data: RSVPEmailData) {

Attending: ${attendingDisplay}

Bringing Guests: ${bringingGuestsDisplay} (${guestCount})

Guest Names: ${guestList}

-

Items Bringing (from needed list): ${itemsList}

+

Items Claimed: ${itemsList}

Other Items: ${otherItemsList}

Manage RSVPs for this event

View all RSVPs for this event

From 05e1e30384867d17f1bd97fe778a3c0890648179 Mon Sep 17 00:00:00 2001 From: Joshua Ryder Date: Mon, 5 May 2025 10:45:22 -0400 Subject: [PATCH 15/32] Update README.md Added info for email notifications --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6820f83..c87d874 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This project was created completely by the [Cursor AI Code Editor](https://www.c - Upload custom wallpapers to personalize event pages - Track RSVPs and guest counts - Comprehensive admin interface for event management + - Email notifications for submitted RSVPs - Item Coordination - Create and manage lists of needed items for events @@ -112,6 +113,10 @@ docker run -d --name rsvp-manager \ npm start ``` +## Email Notifications (Currently in dev branch!) + +By setting up the environment variables in the `docker-compose.yml`, you can have notifications sent to the recipients of your choice when someone submits an RSVP to an event. The notification will include the details of their submission and links to view or manage the RSVPs for that event. + ## Authentication with Authentik This application is compatible with Authentik. Make sure to create a Proxy Provider for Forward auth (single application). To protect the admin & create routes, add the following configuration to your Nginx Proxy Manager config in the Advanced section. For other web server applications, see the Setup area in Authentik on the Provider page for this app and setup the routes accordingly. From efe143ca68212a4b41bedc32afee175fc26872a4 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 18:27:09 -0400 Subject: [PATCH 16/32] Add max guests per RSVP feature to event creation and RSVP forms --- backend/src/index.ts | 25 +++++-- frontend/src/components/EventForm.tsx | 22 +++++- frontend/src/components/RSVPForm.tsx | 98 ++++++++++++++++++++------- frontend/src/types.ts | 3 +- 4 files changed, 116 insertions(+), 32 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 739ceb1..05f8482 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -152,7 +152,7 @@ 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 } = req.body; + const { title, description, date, location, needed_items, rsvp_cutoff_date, max_guests_per_rsvp } = req.body; const wallpaperPath = req.file ? `${req.file.filename}` : null; // Generate a slug from the title @@ -170,9 +170,12 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r 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; + const result = await db.run( - 'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date] + 'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date, maxGuests] ); res.status(201).json({ @@ -180,7 +183,8 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r slug, wallpaper: wallpaperPath ? `/uploads/wallpapers/${wallpaperPath}` : null, needed_items: parsedNeededItems, - rsvp_cutoff_date + rsvp_cutoff_date, + max_guests_per_rsvp: maxGuests }); } catch (error) { console.error('Error creating event:', error); @@ -448,7 +452,7 @@ 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 } = req.body; + const { title, description, date, location, needed_items, rsvp_cutoff_date, max_guests_per_rsvp } = req.body; // Verify the event exists const eventRows = await db.all('SELECT * FROM events WHERE slug = ?', [slug]); @@ -469,6 +473,11 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque console.error('Error parsing needed_items:', e); } + // 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) : + eventRows[0].max_guests_per_rsvp || 0; + // Handle wallpaper update let wallpaperPath = eventRows[0].wallpaper; if (req.file) { @@ -486,7 +495,7 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque // Update the event await db.run( - 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ? WHERE slug = ?', + 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ? WHERE slug = ?', [ title ?? eventRows[0].title, description === undefined ? eventRows[0].description : description, @@ -495,6 +504,7 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque JSON.stringify(parsedNeededItems), rsvp_cutoff_date !== undefined ? rsvp_cutoff_date : eventRows[0].rsvp_cutoff_date, wallpaperPath, + maxGuests, slug ] ); @@ -543,6 +553,7 @@ async function initializeDatabase() { needed_items TEXT, wallpaper TEXT, rsvp_cutoff_date TEXT, + max_guests_per_rsvp INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); @@ -579,4 +590,4 @@ app.get('*', (req: Request, res: Response) => { app.listen(port, async () => { console.log(`Server running on port ${port}`); await connectToDatabase(); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 6c0a55f..e43e3ef 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -43,6 +43,7 @@ interface FormData { location: string; needed_items: string[]; rsvp_cutoff_date: string; + max_guests_per_rsvp: number; } const EventForm: React.FC = () => { @@ -54,6 +55,7 @@ const EventForm: React.FC = () => { location: '', needed_items: [], rsvp_cutoff_date: '', + max_guests_per_rsvp: 0, }); const [wallpaper, setWallpaper] = useState(null); const [currentItem, setCurrentItem] = useState(''); @@ -198,6 +200,24 @@ const EventForm: React.FC = () => { shrink: true, }} /> + + { + const value = parseInt(e.target.value); + setFormData((prev) => ({ + ...prev, + max_guests_per_rsvp: isNaN(value) ? 0 : value, + })); + }} + variant="outlined" + helperText="Set to 0 for no additional guests, -1 for unlimited" + inputProps={{ min: -1 }} + /> { ); }; -export default EventForm; \ No newline at end of file +export default EventForm; diff --git a/frontend/src/components/RSVPForm.tsx b/frontend/src/components/RSVPForm.tsx index 4182f1f..4b4fa4c 100644 --- a/frontend/src/components/RSVPForm.tsx +++ b/frontend/src/components/RSVPForm.tsx @@ -199,15 +199,35 @@ const RSVPForm: React.FC = () => { other_items: '' })); } else if (name === 'bringing_guests') { - // When bringing guests is changed - setFormData(prev => ({ - ...prev, - bringing_guests: value as 'yes' | 'no', - // If changing to 'yes', set guest count to 1 and initialize one empty name field - guest_count: value === 'yes' ? 1 : 0, - // Clear guest names if changing to 'no', otherwise initialize with empty string - guest_names: value === 'no' ? [] : [''] - })); + // When bringing guests is changed + setFormData(prev => { + const maxGuests = event?.max_guests_per_rsvp; + let initialGuestCount = 1; + + // If max_guests_per_rsvp is 0, don't allow guests + if (maxGuests === 0 && value === 'yes') { + return { + ...prev, + bringing_guests: 'no', + guest_count: 0, + guest_names: [] + }; + } + + // If max_guests_per_rsvp is set and not -1 (unlimited), limit initial count + if (maxGuests !== undefined && maxGuests !== -1 && maxGuests < initialGuestCount) { + initialGuestCount = maxGuests; + } + + return { + ...prev, + bringing_guests: value as 'yes' | 'no', + // If changing to 'yes', set guest count to appropriate value + guest_count: value === 'yes' ? initialGuestCount : 0, + // Clear guest names if changing to 'no', otherwise initialize with empty strings + guest_names: value === 'no' ? [] : Array(initialGuestCount).fill('') + }; + }); } else { setFormData(prev => ({ ...prev, @@ -465,19 +485,51 @@ const RSVPForm: React.FC = () => { {formData.bringing_guests === 'yes' && ( <> - + { + const value = parseInt(e.target.value); + if (isNaN(value)) return; + + // Check if there's a maximum guest limit + const maxGuests = event?.max_guests_per_rsvp; + let newCount = value; + + // If max_guests_per_rsvp is set and not -1 (unlimited), enforce the limit + if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) { + newCount = maxGuests; + } + + // Ensure count is at least 1 + if (newCount < 1) newCount = 1; + + setFormData(prev => ({ + ...prev, + guest_count: newCount, + guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '') + })); + }} + fullWidth + variant="outlined" + required + inputProps={{ + min: 1, + max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp + }} + error={formData.guest_count < 1} + helperText={ + formData.guest_count < 1 + ? "Number of guests must be at least 1" + : event?.max_guests_per_rsvp === 0 + ? "No additional guests allowed for this event" + : event?.max_guests_per_rsvp === -1 + ? "No limit on number of guests" + : `Maximum ${event?.max_guests_per_rsvp} additional guests allowed` + } + /> {Array.from({ length: formData.guest_count }).map((_, index) => ( { ); }; -export default RSVPForm; \ No newline at end of file +export default RSVPForm; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bf444d5..02a52cd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -9,6 +9,7 @@ export interface Event { needed_items: string[]; wallpaper?: string; rsvp_cutoff_date?: string; + max_guests_per_rsvp?: number; } export interface Rsvp { @@ -22,4 +23,4 @@ export interface Rsvp { items_bringing: string[] | string; other_items?: string; created_at: string; -} \ No newline at end of file +} From cedd7b325ff52b8676d9d987fb3b36ec39f2a035 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 18:42:55 -0400 Subject: [PATCH 17/32] Add email notification settings to event creation form --- backend/src/index.ts | 131 ++++++++++++++++++-------- frontend/src/components/EventForm.tsx | 46 +++++++++ frontend/src/types.ts | 2 + 3 files changed, 141 insertions(+), 38 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 05f8482..f561b93 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -152,7 +152,17 @@ 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, max_guests_per_rsvp } = req.body; + const { + title, + description, + date, + location, + needed_items, + rsvp_cutoff_date, + max_guests_per_rsvp, + email_notifications_enabled, + email_recipients + } = req.body; const wallpaperPath = req.file ? `${req.file.filename}` : null; // Generate a slug from the title @@ -173,9 +183,12 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r // 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 + const emailNotificationsEnabled = email_notifications_enabled === 'true' || email_notifications_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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date, maxGuests] + '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 || ''] ); res.status(201).json({ @@ -184,7 +197,9 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r wallpaper: wallpaperPath ? `/uploads/wallpapers/${wallpaperPath}` : null, needed_items: parsedNeededItems, rsvp_cutoff_date, - max_guests_per_rsvp: maxGuests + max_guests_per_rsvp: maxGuests, + email_notifications_enabled: emailNotificationsEnabled, + email_recipients: email_recipients || '' }); } catch (error) { console.error('Error creating event:', error); @@ -262,13 +277,19 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { const { slug } = req.params; const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body; - const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); + // 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 eventId = eventRows[0].id; + const event = eventRows[0]; + const eventId = event.id; + const eventTitle = event.title; + const eventSlug = event.slug; + const emailNotificationsEnabled = event.email_notifications_enabled; + const eventEmailRecipients = event.email_recipients; // Parse items_bringing if it's a string let parsedItemsBringing: string[] = []; @@ -299,39 +320,49 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { [eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || ''] ); - // Fetch event title and slug for the email - const eventInfo = await db.get('SELECT title, slug FROM events WHERE id = ?', [eventId]); - const eventTitle = eventInfo ? eventInfo.title : slug; - const eventSlug = eventInfo ? eventInfo.slug : slug; - - // Optionally send RSVP confirmation email to recipients - let recipients: string[] = []; - if (process.env.EMAIL_RECIPIENTS) { - recipients = process.env.EMAIL_RECIPIENTS.split(',').map(addr => addr.trim()).filter(Boolean); - } else if (process.env.EMAIL_USER) { - recipients = [process.env.EMAIL_USER]; - } - if (recipients.length > 0) { - try { - for (const to of recipients) { - await sendRSVPEmail({ - eventTitle, - eventSlug, - name, - attending, - bringingGuests: bringing_guests, - guestCount: guest_count, - guestNames: parsedGuestNames, - itemsBringing: parsedItemsBringing, - otherItems: other_items || '', - to, - }); + // Send email notifications if enabled for this event + if (emailNotificationsEnabled) { + // Get recipients from event settings + let recipients: string[] = []; + + // First try to use the event's email recipients + if (eventEmailRecipients) { + recipients = eventEmailRecipients.split(',').map(addr => addr.trim()).filter(Boolean); + } + + // If no event recipients, fall back to environment variables + if (recipients.length === 0) { + if (process.env.EMAIL_RECIPIENTS) { + recipients = process.env.EMAIL_RECIPIENTS.split(',').map(addr => addr.trim()).filter(Boolean); + } else if (process.env.EMAIL_USER) { + recipients = [process.env.EMAIL_USER]; } - } catch (emailErr) { - console.error('Error sending RSVP email:', emailErr); + } + + if (recipients.length > 0) { + try { + for (const to of recipients) { + await sendRSVPEmail({ + eventTitle, + eventSlug, + name, + attending, + bringingGuests: bringing_guests, + guestCount: guest_count, + guestNames: parsedGuestNames, + itemsBringing: parsedItemsBringing, + otherItems: other_items || '', + to, + }); + } + } catch (emailErr) { + console.error('Error sending RSVP email:', emailErr); + } + } else { + console.warn('No email recipients set. Skipping RSVP email notification.'); } } else { - console.warn('No email recipients set. Skipping RSVP email notification.'); + console.log('Email notifications disabled for this event. Skipping RSVP email notification.'); } // Return the complete RSVP data including the parsed arrays @@ -452,7 +483,17 @@ 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, max_guests_per_rsvp } = req.body; + const { + title, + description, + date, + location, + needed_items, + rsvp_cutoff_date, + max_guests_per_rsvp, + email_notifications_enabled, + email_recipients + } = req.body; // Verify the event exists const eventRows = await db.all('SELECT * FROM events WHERE slug = ?', [slug]); @@ -477,6 +518,16 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque 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 + const emailNotificationsEnabled = email_notifications_enabled !== undefined ? + (email_notifications_enabled === 'true' || email_notifications_enabled === true) : + eventRows[0].email_notifications_enabled; + + // Get email recipients + const emailRecipients = email_recipients !== undefined ? + email_recipients : + eventRows[0].email_recipients || ''; // Handle wallpaper update let wallpaperPath = eventRows[0].wallpaper; @@ -495,7 +546,7 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque // Update the event await db.run( - 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ? WHERE slug = ?', + 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ? WHERE slug = ?', [ title ?? eventRows[0].title, description === undefined ? eventRows[0].description : description, @@ -505,6 +556,8 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque rsvp_cutoff_date !== undefined ? rsvp_cutoff_date : eventRows[0].rsvp_cutoff_date, wallpaperPath, maxGuests, + emailNotificationsEnabled ? 1 : 0, + emailRecipients, slug ] ); @@ -554,6 +607,8 @@ async function initializeDatabase() { wallpaper TEXT, rsvp_cutoff_date TEXT, max_guests_per_rsvp INTEGER DEFAULT 0, + email_notifications_enabled BOOLEAN DEFAULT 0, + email_recipients TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index e43e3ef..73a8084 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -10,6 +10,8 @@ import { Chip, IconButton, styled, + Checkbox, + FormControlLabel, } from '@mui/material'; import WallpaperIcon from '@mui/icons-material/Wallpaper'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -44,6 +46,8 @@ interface FormData { needed_items: string[]; rsvp_cutoff_date: string; max_guests_per_rsvp: number; + email_notifications_enabled: boolean; + email_recipients: string; } const EventForm: React.FC = () => { @@ -56,6 +60,8 @@ const EventForm: React.FC = () => { needed_items: [], rsvp_cutoff_date: '', max_guests_per_rsvp: 0, + email_notifications_enabled: false, + email_recipients: '', }); const [wallpaper, setWallpaper] = useState(null); const [currentItem, setCurrentItem] = useState(''); @@ -218,6 +224,46 @@ const EventForm: React.FC = () => { helperText="Set to 0 for no additional guests, -1 for unlimited" inputProps={{ min: -1 }} /> + + + { + setFormData((prev) => ({ + ...prev, + email_notifications_enabled: e.target.checked, + })); + }} + sx={{ + color: 'rgba(255, 255, 255, 0.7)', + '&.Mui-checked': { + color: '#90caf9', + }, + }} + /> + } + label="Enable Email Notifications" + sx={{ + color: 'rgba(255, 255, 255, 0.9)', + }} + /> + + + {formData.email_notifications_enabled && ( + + )} Date: Fri, 16 May 2025 18:46:07 -0400 Subject: [PATCH 18/32] Remove EMAIL_RECIPIENTS environment variable and update email notification logic --- backend/src/index.ts | 12 ++++-------- docker-compose-build.yml | 3 +-- docker-compose.yml | 3 +-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index f561b93..242d4c4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -325,18 +325,14 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { // Get recipients from event settings let recipients: string[] = []; - // First try to use the event's email recipients + // Use the event's email recipients if (eventEmailRecipients) { recipients = eventEmailRecipients.split(',').map(addr => addr.trim()).filter(Boolean); } - // If no event recipients, fall back to environment variables - if (recipients.length === 0) { - if (process.env.EMAIL_RECIPIENTS) { - recipients = process.env.EMAIL_RECIPIENTS.split(',').map(addr => addr.trim()).filter(Boolean); - } else if (process.env.EMAIL_USER) { - recipients = [process.env.EMAIL_USER]; - } + // 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) { diff --git a/docker-compose-build.yml b/docker-compose-build.yml index c23e8a7..51626b2 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -17,11 +17,10 @@ services: - EMAIL_PASS=yourpassword - EMAIL_FROM_NAME=RSVP Manager - EMAIL_FROM_ADDRESS=your@email.com - - EMAIL_RECIPIENTS=admin1@email.com,admin2@email.com - EMAIL_SECURE=false - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped volumes: data: - uploads: + uploads: diff --git a/docker-compose.yml b/docker-compose.yml index 88c3d61..37619ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,10 +16,9 @@ services: - EMAIL_FROM_NAME=RSVP Manager - EMAIL_FROM_ADDRESS=your@email.com - EMAIL_SECURE=false - - EMAIL_RECIPIENTS=admin1@email.com,admin2@email.com - FRONTEND_BASE_URL=https://your-frontend-domain.com restart: unless-stopped volumes: data: - uploads: + uploads: From 9c68ec3c46956df7e593f66921a9254358f63779 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 18:48:57 -0400 Subject: [PATCH 19/32] Fix TypeScript error with explicit type for addr parameter --- backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 242d4c4..2bca110 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -327,7 +327,7 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { // Use the event's email recipients if (eventEmailRecipients) { - recipients = eventEmailRecipients.split(',').map(addr => addr.trim()).filter(Boolean); + 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 From 726338b4c81ec4be91692d1b6ae6b8ccfeebf55a Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 19:00:44 -0400 Subject: [PATCH 20/32] Add email notification settings to EventAdmin update modal --- frontend/src/components/EventAdmin.tsx | 53 ++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/EventAdmin.tsx b/frontend/src/components/EventAdmin.tsx index e4cb04c..569c7ce 100644 --- a/frontend/src/components/EventAdmin.tsx +++ b/frontend/src/components/EventAdmin.tsx @@ -60,6 +60,9 @@ interface Event { needed_items?: string[] | string; wallpaper?: string; rsvp_cutoff_date?: string; + max_guests_per_rsvp?: number; + email_notifications_enabled?: boolean; + email_recipients?: string; } interface EditFormData { @@ -105,7 +108,9 @@ const EventAdmin: React.FC = () => { location: '', date: '', rsvp_cutoff_date: '', - wallpaper: null as File | null + wallpaper: null as File | null, + email_notifications_enabled: false, + email_recipients: '' }); useEffect(() => { @@ -567,7 +572,9 @@ const EventAdmin: React.FC = () => { location: event.location, date: event.date.slice(0, 16), // Format date for datetime-local input rsvp_cutoff_date: event.rsvp_cutoff_date ? event.rsvp_cutoff_date.slice(0, 16) : '', - wallpaper: null + wallpaper: null, + email_notifications_enabled: event.email_notifications_enabled || false, + email_recipients: event.email_recipients || '' }); setUpdateInfoDialogOpen(true); }; @@ -584,6 +591,8 @@ const EventAdmin: React.FC = () => { formData.append('rsvp_cutoff_date', updateForm.rsvp_cutoff_date); formData.append('title', event.title); // Keep existing title formData.append('needed_items', JSON.stringify(event.needed_items)); // Keep existing needed items + formData.append('email_notifications_enabled', updateForm.email_notifications_enabled.toString()); + formData.append('email_recipients', updateForm.email_recipients); // Append wallpaper if a new one was selected if (updateForm.wallpaper) { @@ -602,7 +611,9 @@ const EventAdmin: React.FC = () => { location: updateForm.location, date: updateForm.date, rsvp_cutoff_date: updateForm.rsvp_cutoff_date, - wallpaper: response.data.wallpaper || prev.wallpaper + wallpaper: response.data.wallpaper || prev.wallpaper, + email_notifications_enabled: updateForm.email_notifications_enabled, + email_recipients: updateForm.email_recipients } : null); setUpdateInfoDialogOpen(false); @@ -1125,6 +1136,40 @@ const EventAdmin: React.FC = () => { shrink: true, }} /> + + + Email Notifications + + setUpdateForm(prev => ({ + ...prev, + email_notifications_enabled: e.target.checked + }))} + /> + } + label="Enable Email Notifications" + /> + + {updateForm.email_notifications_enabled && ( + setUpdateForm(prev => ({ + ...prev, + email_recipients: e.target.value + }))} + variant="outlined" + placeholder="email1@example.com, email2@example.com" + helperText="Enter email addresses separated by commas" + sx={{ mt: 2 }} + /> + )} + + Wallpaper @@ -1181,4 +1226,4 @@ const EventAdmin: React.FC = () => { ); }; -export default EventAdmin; \ No newline at end of file +export default EventAdmin; From 1766da9c25756f6d5e9a692fbac1548d9945d1a9 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 19:08:43 -0400 Subject: [PATCH 21/32] Move email notification settings to bottom of event creation form --- frontend/src/components/EventForm.tsx | 86 ++++++++++++++------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 73a8084..940bcde 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -224,46 +224,6 @@ const EventForm: React.FC = () => { helperText="Set to 0 for no additional guests, -1 for unlimited" inputProps={{ min: -1 }} /> - - - { - setFormData((prev) => ({ - ...prev, - email_notifications_enabled: e.target.checked, - })); - }} - sx={{ - color: 'rgba(255, 255, 255, 0.7)', - '&.Mui-checked': { - color: '#90caf9', - }, - }} - /> - } - label="Enable Email Notifications" - sx={{ - color: 'rgba(255, 255, 255, 0.9)', - }} - /> - - - {formData.email_notifications_enabled && ( - - )} { + + + Email Notifications + + + + { + setFormData((prev) => ({ + ...prev, + email_notifications_enabled: e.target.checked, + })); + }} + sx={{ + color: 'rgba(255, 255, 255, 0.7)', + '&.Mui-checked': { + color: '#90caf9', + }, + }} + /> + } + label="Enable Email Notifications" + sx={{ + color: 'rgba(255, 255, 255, 0.9)', + }} + /> + + + {formData.email_notifications_enabled && ( + + )} + + + + + + + ); + } + + return ( + + + + + + Edit Your RSVP + + + {error && ( + + {error} + + )} + + + + + + Are you attending? + + + + {formData.attending === 'yes' && ( + <> + + Are you bringing any guests? + + + + {formData.bringing_guests === 'yes' && ( + <> + { + const value = parseInt(e.target.value); + if (isNaN(value)) return; + + const maxGuests = event?.max_guests_per_rsvp; + let newCount = value; + + if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) { + newCount = maxGuests; + } + + if (newCount < 1) newCount = 1; + + setFormData(prev => ({ + ...prev, + guest_count: newCount, + guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '') + })); + }} + fullWidth + variant="outlined" + required + inputProps={{ + min: 1, + max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp + }} + error={formData.guest_count < 1} + helperText={ + formData.guest_count < 1 + ? "Number of guests must be at least 1" + : event?.max_guests_per_rsvp === 0 + ? "No additional guests allowed for this event" + : event?.max_guests_per_rsvp === -1 + ? "No limit on number of guests" + : `Maximum ${event?.max_guests_per_rsvp} additional guests allowed` + } + /> + + {Array.from({ length: formData.guest_count }).map((_, index) => ( + + ))} + + )} + + {neededItems.length > 0 && ( + + What items are you bringing? + + + )} + + + + )} + + + + + + + + ); +}; + +export default RSVPEditForm; From 89c0a1128c87afb18557a4e1e25d5539bc3ca7af Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:20:13 -0400 Subject: [PATCH 24/32] Fix TypeScript errors in RSVPForm and RSVPEditForm --- frontend/src/components/RSVPEditForm.tsx | 1 + frontend/src/components/RSVPForm.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/components/RSVPEditForm.tsx b/frontend/src/components/RSVPEditForm.tsx index 87bd570..6cca686 100644 --- a/frontend/src/components/RSVPEditForm.tsx +++ b/frontend/src/components/RSVPEditForm.tsx @@ -17,6 +17,7 @@ import { ListItemText, OutlinedInput, Chip, + FormControlLabel, // Import FormControlLabel } from '@mui/material'; import { Event } from '../types'; diff --git a/frontend/src/components/RSVPForm.tsx b/frontend/src/components/RSVPForm.tsx index 62a0526..bff1ebd 100644 --- a/frontend/src/components/RSVPForm.tsx +++ b/frontend/src/components/RSVPForm.tsx @@ -17,6 +17,7 @@ import { ListItemText, OutlinedInput, Chip, + FormControlLabel, // Import FormControlLabel } from '@mui/material'; import VisibilityIcon from '@mui/icons-material/Visibility'; import { Event } from '../types'; From 1437b8e0ee50ffb79fe5e4909b872494fbe0fafe Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:27:39 -0400 Subject: [PATCH 25/32] Update 'Needed Items' chip color in EventAdmin table to green --- frontend/src/components/EventAdmin.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/EventAdmin.tsx b/frontend/src/components/EventAdmin.tsx index 569c7ce..4de3b12 100644 --- a/frontend/src/components/EventAdmin.tsx +++ b/frontend/src/components/EventAdmin.tsx @@ -861,6 +861,7 @@ const EventAdmin: React.FC = () => { )) : @@ -869,6 +870,7 @@ const EventAdmin: React.FC = () => { )) : From b4f133b077acd421ed04283972f14b6b35d73a05 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:29:57 -0400 Subject: [PATCH 26/32] Update RSVP confirmation email subject and body --- backend/src/email.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/email.ts b/backend/src/email.ts index 4b0442d..3b49334 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -96,10 +96,9 @@ export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) { editLink, } = data; - const subject = `Edit Your RSVP for ${eventTitle}`; + const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line const html = ` -

Edit Your RSVP

Hello ${name},

You have successfully RSVP'd for the event "${eventTitle}".

You can edit your RSVP at any time by clicking the link below:

From 65367085c4c19cfe689c4055db55ae4d91fd3a9e Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:47:27 -0400 Subject: [PATCH 27/32] Implement read-only mode for RSVP edit form after cutoff date --- frontend/src/components/RSVPEditForm.tsx | 195 +++++++++++++++++++++-- 1 file changed, 181 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/RSVPEditForm.tsx b/frontend/src/components/RSVPEditForm.tsx index 6cca686..df40818 100644 --- a/frontend/src/components/RSVPEditForm.tsx +++ b/frontend/src/components/RSVPEditForm.tsx @@ -51,6 +51,7 @@ const RSVPEditForm: React.FC = () => { const navigate = useNavigate(); const [event, setEvent] = useState(null); const [rsvpId, setRsvpId] = useState(null); + const [isEventClosed, setIsEventClosed] = useState(false); // New state to track if event is closed useEffect(() => { const fetchRsvpDetails = async () => { @@ -65,6 +66,21 @@ const RSVPEditForm: React.FC = () => { throw new Error('Failed to fetch data from server'); } + // Check if event is closed for RSVPs + if (eventResponse.data.rsvp_cutoff_date) { + const cutoffDate = new Date(eventResponse.data.rsvp_cutoff_date); + if (new Date() > cutoffDate) { + setIsEventClosed(true); // Set state if closed + } + } + + setEvent(eventResponse.data); + setRsvpId(rsvpResponse.data.id); + + if (!eventResponse.data || !rsvpResponse.data || !rsvpsResponse.data) { + throw new Error('Failed to fetch data from server'); + } + setEvent(eventResponse.data); setRsvpId(rsvpResponse.data.id); @@ -253,6 +269,13 @@ const RSVPEditForm: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + // Prevent submission if the event is closed + if (isEventClosed) { + setError('Event registration is closed. Changes are not allowed.'); + return; + } + setIsSubmitting(true); setError(null); @@ -385,6 +408,12 @@ const RSVPEditForm: React.FC = () => { Edit Your RSVP + {isEventClosed && ( + + Event registration is closed. Changes are not allowed. Please contact the event organizer for assistance. + + )} + {error && ( {error} @@ -400,6 +429,7 @@ const RSVPEditForm: React.FC = () => { required fullWidth variant="outlined" + disabled={isEventClosed} // Disable if event is closed /> @@ -534,20 +564,157 @@ const RSVPEditForm: React.FC = () => { )} - + {/* Disable if event is closed */} + Are you attending? + + + + {formData.attending === 'yes' && ( + <> + {/* Disable if event is closed */} + Are you bringing any guests? + + + + {formData.bringing_guests === 'yes' && ( + <> + { + const value = parseInt(e.target.value); + if (isNaN(value)) return; + + const maxGuests = event?.max_guests_per_rsvp; + let newCount = value; + + if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) { + newCount = maxGuests; + } + + if (newCount < 1) newCount = 1; + + setFormData(prev => ({ + ...prev, + guest_count: newCount, + guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '') + })); + }} + fullWidth + variant="outlined" + required + inputProps={{ + min: 1, + max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp + }} + error={formData.guest_count < 1} + helperText={ + formData.guest_count < 1 + ? "Number of guests must be at least 1" + : event?.max_guests_per_rsvp === 0 + ? "No additional guests allowed for this event" + : event?.max_guests_per_rsvp === -1 + ? "No limit on number of guests" + : `Maximum ${event?.max_guests_per_rsvp} additional guests allowed` + } + disabled={isEventClosed} // Disable if event is closed + /> + + {Array.from({ length: formData.guest_count }).map((_, index) => ( + + ))} + + )} + + {neededItems.length > 0 && ( + {/* Disable if event is closed */} + What items are you bringing? + + + )} + + + + )} + + {!isEventClosed && ( // Hide submit button if event is closed + + )} From 15bb6823b8fe2d5860586bc5e9633f35bcb76bf4 Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:57:46 -0400 Subject: [PATCH 28/32] Fix form duplication and ensure all fields are disabled when event is closed in RSVPEditForm --- frontend/src/components/RSVPEditForm.tsx | 140 ----------------------- 1 file changed, 140 deletions(-) diff --git a/frontend/src/components/RSVPEditForm.tsx b/frontend/src/components/RSVPEditForm.tsx index df40818..5e0902e 100644 --- a/frontend/src/components/RSVPEditForm.tsx +++ b/frontend/src/components/RSVPEditForm.tsx @@ -77,13 +77,6 @@ const RSVPEditForm: React.FC = () => { setEvent(eventResponse.data); setRsvpId(rsvpResponse.data.id); - if (!eventResponse.data || !rsvpResponse.data || !rsvpsResponse.data) { - throw new Error('Failed to fetch data from server'); - } - - setEvent(eventResponse.data); - setRsvpId(rsvpResponse.data.id); - // Pre-fill the form with existing RSVP data setFormData({ name: rsvpResponse.data.name, @@ -432,138 +425,6 @@ const RSVPEditForm: React.FC = () => { disabled={isEventClosed} // Disable if event is closed /> - - Are you attending? - - - - {formData.attending === 'yes' && ( - <> - - Are you bringing any guests? - - - - {formData.bringing_guests === 'yes' && ( - <> - { - const value = parseInt(e.target.value); - if (isNaN(value)) return; - - const maxGuests = event?.max_guests_per_rsvp; - let newCount = value; - - if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) { - newCount = maxGuests; - } - - if (newCount < 1) newCount = 1; - - setFormData(prev => ({ - ...prev, - guest_count: newCount, - guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '') - })); - }} - fullWidth - variant="outlined" - required - inputProps={{ - min: 1, - max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp - }} - error={formData.guest_count < 1} - helperText={ - formData.guest_count < 1 - ? "Number of guests must be at least 1" - : event?.max_guests_per_rsvp === 0 - ? "No additional guests allowed for this event" - : event?.max_guests_per_rsvp === -1 - ? "No limit on number of guests" - : `Maximum ${event?.max_guests_per_rsvp} additional guests allowed` - } - /> - - {Array.from({ length: formData.guest_count }).map((_, index) => ( - - ))} - - )} - - {neededItems.length > 0 && ( - - What items are you bringing? - - - )} - - - - )} - {/* Disable if event is closed */} Are you attending?