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 diff --git a/README.md b/README.md index d91c3f9..b8a20cc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A modern event RSVP management system with customizable backgrounds and item coordination. -This project was created completely by the [Cursor AI Code Editor](https://www.cursor.com/)! +This project was created completely by the [Cursor AI Code Editor](https://www.cursor.com/) & Visual Studio Code with [Cline](https://cline.bot/)! ## Features @@ -12,6 +12,8 @@ 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 + - Individual submission links so users can edit their submissions - Item Coordination - Create and manage lists of needed items for events @@ -77,6 +79,20 @@ These variables below are all for the email notifications. If you want to be abl | EMAIL_FROM_ADDRESS | Email displayed in the "from" on email notifications | | FRONTEND_BASE_URL | The main URL for your instance. This will be used in the links that are sent in the email notificiations, eg. https://rsvp.example.com | +#### Environment Variables + +These variables below are all for the email notifications. If you want to be able to send email notifications, each of these needs to be provided and filled out. + +| Variable | Description | +| ------------- | ------------- | +| EMAIL_HOST | Your email provider's host name | +| EMAIL_PORT | Your email provider's SMTP port | +| EMAIL_USER | Login username for your email provider | +| EMAIL_PASS | Login password for your email provider | +| EMAIL_FROM_NAME | Name displayed in the "from" on email notifications | +| EMAIL_FROM_ADDRESS | Email displayed in the "from" on email notifications | +| FRONTEND_BASE_URL | The main URL for your instance. This will be used in the links that are sent in the email notificiations, eg. https://rsvp.example.com | + #### Docker Compose 1. Clone the repository. @@ -134,6 +150,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. diff --git a/backend/package.json b/backend/package.json index 2f4e899..031e5ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,13 +14,17 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "sqlite3": "^5.1.6", - "sqlite": "^4.1.2" + "sqlite": "^4.1.2", + "nodemailer": "^6.9.8", + "multer": "^1.4.5-lts.1" }, "devDependencies": { "@types/express": "^4.17.17", "@types/node": "^20.4.5", "@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" diff --git a/backend/src/email.ts b/backend/src/email.ts new file mode 100644 index 0000000..3b49334 --- /dev/null +++ b/backend/src/email.ts @@ -0,0 +1,119 @@ +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; + eventSlug: 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, + eventSlug, + name, + attending, + bringingGuests, + guestCount, + guestNames, + itemsBringing, + otherItems, + 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 || ''; + const manageRsvpsUrl = `${baseUrl}/admin/events/${eventSlug}`; + const viewRsvpsUrl = `${baseUrl}/view/events/${eventSlug}`; + + const html = ` +

RSVP Confirmation

+

Event: ${eventTitle}

+

Name: ${name}

+

Attending: ${attendingDisplay}

+

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

+

Guest Names: ${guestList}

+

Items Claimed: ${itemsList}

+

Other Items: ${otherItemsList}

+

Manage RSVPs for this event

+

View all RSVPs for this event

+ `; + + await transporter.sendMail({ + from: { + name: process.env.EMAIL_FROM_NAME || '', + address: process.env.EMAIL_FROM_ADDRESS || process.env.EMAIL_USER || '', + }, + to, + subject, + html, + }); +} + +export interface RSVPEditLinkEmailData { + eventTitle: string; + eventSlug: string; + name: string; + to: string; + editLink: string; +} + +export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) { + const { + eventTitle, + eventSlug, + name, + to, + editLink, + } = data; + + const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line + + const html = ` +

Hello ${name},

+

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

+

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

+

${editLink}

+

Please save this email if you think you might need to edit your submission later.

+

Thank you!

+ `; + + 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 8ea0d2e..96c2516 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,9 +6,21 @@ 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 dotenv.config(); +// Function to generate a random alphanumeric string +function generateAlphanumericId(length: number): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + const app = express(); const port = process.env.PORT || 3000; @@ -151,7 +163,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 } = 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 @@ -169,9 +191,15 @@ 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; + + // 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) 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, 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({ @@ -179,7 +207,10 @@ 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, + email_notifications_enabled: emailNotificationsEnabled, + email_recipients: email_recipients || '' }); } catch (error) { console.error('Error creating event:', error); @@ -255,15 +286,31 @@ 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, other_items } = req.body; + 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 + } = 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[] = []; @@ -289,12 +336,91 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { console.error('Error parsing guest_names:', e); } + // Generate a unique edit ID + let editId = ''; + let isUnique = false; + while (!isUnique) { + editId = generateAlphanumericId(16); + const existingRsvp = await db.get('SELECT id FROM rsvps WHERE edit_id = ?', [editId]); + if (!existingRsvp) { + isUnique = true; + } + } + const result = await db.run( - 'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || ''] + '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] ); - // Return the complete RSVP data including the parsed arrays + // 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) { + 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 to event recipients:', emailErr); + } + } else { + console.warn('No event email recipients set. Skipping RSVP email notification to event recipients.'); + } + } else { + console.log('Email notifications disabled for this event. Skipping RSVP email notification to event recipients.'); + } + + // 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) { + try { + const editLink = `${process.env.FRONTEND_BASE_URL}/events/${eventSlug}/rsvp/edit/${editId}`; + await sendRSVPEditLinkEmail({ + eventTitle, + eventSlug, + name, + to: submitterEmail, + editLink, + }); + console.log(`Sent RSVP edit link email to ${submitterEmail}`); + } catch (emailErr) { + console.error('Error sending RSVP edit link email:', emailErr); + } + } else if (sendEmailConfirmationBool && !submitterEmail) { + 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.'); + } else { + console.log('Email confirmation not requested. Skipping edit link email.'); + } + + + // Return the complete RSVP data including the parsed arrays and edit_id res.status(201).json({ id: result.lastID, event_id: eventId, @@ -305,6 +431,7 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { guest_names: parsedGuestNames, items_bringing: parsedItemsBringing, other_items: other_items || '', + edit_id: editId, created_at: new Date().toISOString() }); } catch (error) { @@ -313,6 +440,34 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => { } }); +// Get RSVP by edit ID +app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { + try { + const { editId } = req.params; + const rsvp = await db.get('SELECT * FROM rsvps WHERE edit_id = ?', [editId]); + + if (!rsvp) { + return res.status(404).json({ error: 'RSVP not found' }); + } + + // Parse arrays 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) : []; + } catch (e) { + console.error('Error parsing arrays in response:', e); + rsvp.items_bringing = []; + rsvp.guest_names = []; + } + + res.json(rsvp); + } catch (error) { + console.error('Error fetching RSVP by edit ID:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + + app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { try { const { slug, id } = req.params; @@ -333,6 +488,83 @@ app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => } }); +// Update RSVP by edit ID +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; + + // Find the RSVP by edit_id + const rsvp = await db.get('SELECT id, event_id FROM rsvps WHERE edit_id = ?', [editId]); + + if (!rsvp) { + return res.status(404).json({ error: 'RSVP not found' }); + } + + const rsvpId = rsvp.id; + const eventId = rsvp.event_id; + + // Parse items_bringing if it's a string + let parsedItemsBringing: string[] = []; + try { + if (typeof items_bringing === 'string') { + parsedItemsBringing = JSON.parse(items_bringing); + } else if (Array.isArray(items_bringing)) { + parsedItemsBringing = items_bringing; + } + } catch (e) { + console.error('Error parsing items_bringing:', e); + } + + // Parse guest_names if it's a string + let parsedGuestNames: string[] = []; + try { + 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)) { + // If it's already an array + parsedGuestNames = guest_names.filter(name => name && name.trim()); + } + } catch (e) { + console.error('Error parsing guest_names:', e); + parsedGuestNames = []; + } + + // 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] + ); + + // Get the updated RSVP to verify and return + const updatedRsvp = await db.get('SELECT * FROM rsvps WHERE id = ?', [rsvpId]); + + if (!updatedRsvp) { + return res.status(404).json({ error: 'RSVP not found after update' }); + } + + // Parse arrays 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) : []; + } catch (e) { + console.error('Error parsing arrays in response:', e); + updatedRsvp.items_bringing = []; + updatedRsvp.guest_names = []; + } + + res.json(updatedRsvp); + } catch (error) { + console.error('Error updating RSVP by edit ID:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + + // Update RSVP app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { try { @@ -412,7 +644,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 } = 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]); @@ -433,6 +675,21 @@ 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; + + // 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; if (req.file) { @@ -450,7 +707,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 = ?, email_notifications_enabled = ?, email_recipients = ? WHERE slug = ?', [ title ?? eventRows[0].title, description === undefined ? eventRows[0].description : description, @@ -459,6 +716,9 @@ 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, + emailNotificationsEnabled ? 1 : 0, + emailRecipients, slug ] ); @@ -507,6 +767,9 @@ async function initializeDatabase() { needed_items TEXT, 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 ) `); @@ -523,6 +786,7 @@ async function initializeDatabase() { guest_names TEXT, items_bringing TEXT, other_items TEXT, + edit_id TEXT UNIQUE, -- Add a column for the unique edit ID created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE ) @@ -543,4 +807,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/docker-compose-build.yml b/docker-compose-build.yml index 8dfbf84..51626b2 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -11,8 +11,16 @@ 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: data: - uploads: + uploads: diff --git a/docker-compose.yml b/docker-compose.yml index f600924..37619ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,16 @@ 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: data: - uploads: + uploads: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7ba567..2aa3115 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import EventForm from './components/EventForm'; import RSVPForm from './components/RSVPForm'; import EventAdmin from './components/EventAdmin'; import EventView from './components/EventView'; +import RSVPEditForm from './components/RSVPEditForm'; // Import the new component import './App.css'; const darkTheme = createTheme({ @@ -71,6 +72,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> {/* Add the new route */} @@ -79,4 +81,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/EventAdmin.tsx b/frontend/src/components/EventAdmin.tsx index e4cb04c..4de3b12 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); @@ -850,6 +861,7 @@ const EventAdmin: React.FC = () => { )) : @@ -858,6 +870,7 @@ const EventAdmin: React.FC = () => { )) : @@ -1125,6 +1138,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 +1228,4 @@ const EventAdmin: React.FC = () => { ); }; -export default EventAdmin; \ No newline at end of file +export default EventAdmin; diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 6c0a55f..940bcde 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'; @@ -43,6 +45,9 @@ interface FormData { location: string; needed_items: string[]; rsvp_cutoff_date: string; + max_guests_per_rsvp: number; + email_notifications_enabled: boolean; + email_recipients: string; } const EventForm: React.FC = () => { @@ -54,6 +59,9 @@ const EventForm: React.FC = () => { location: '', 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(''); @@ -198,6 +206,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 }} + /> { + + + 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 && ( + + )} + + + + 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) && ( + + )} - )} + - - - - - - ))} + + + + ))} diff --git a/frontend/src/components/RSVPEditForm.tsx b/frontend/src/components/RSVPEditForm.tsx new file mode 100644 index 0000000..30158a1 --- /dev/null +++ b/frontend/src/components/RSVPEditForm.tsx @@ -0,0 +1,593 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import { + Box, + Paper, + Typography, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + Container, + Checkbox, + ListItemText, + OutlinedInput, + Chip, + FormControlLabel, // Import FormControlLabel +} from '@mui/material'; +import { Event } from '../types'; + +interface RSVPFormData { + name: string; + attending: string; + bringing_guests: string; + guest_count: number; + guest_names: string[]; + items_bringing: string[]; + other_items: string; +} + +const RSVPEditForm: React.FC = () => { + const { slug, editId } = useParams<{ slug: string; editId: string }>(); + const [formData, setFormData] = useState({ + name: '', + attending: '', + bringing_guests: '', + guest_count: 1, + guest_names: [], + items_bringing: [], + other_items: '' + }); + const [neededItems, setNeededItems] = useState([]); + const [claimedItems, setClaimedItems] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + 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 () => { + try { + const [eventResponse, rsvpResponse, rsvpsResponse] = await Promise.all([ + axios.get(`/api/events/${slug}`), + axios.get(`/api/rsvps/edit/${editId}`), // New endpoint to fetch by editId + axios.get(`/api/events/${slug}/rsvps`) // To get all RSVPs for claimed items + ]); + + if (!eventResponse.data || !rsvpResponse.data || !rsvpsResponse.data) { + 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); + + // Pre-fill the form with existing RSVP data + setFormData({ + name: rsvpResponse.data.name, + attending: rsvpResponse.data.attending, + bringing_guests: rsvpResponse.data.bringing_guests, + guest_count: rsvpResponse.data.guest_count, + guest_names: Array.isArray(rsvpResponse.data.guest_names) ? rsvpResponse.data.guest_names : (typeof rsvpResponse.data.guest_names === 'string' && rsvpResponse.data.guest_names ? JSON.parse(rsvpResponse.data.guest_names) : []), + items_bringing: Array.isArray(rsvpResponse.data.items_bringing) ? rsvpResponse.data.items_bringing : (typeof rsvpResponse.data.items_bringing === 'string' && rsvpResponse.data.items_bringing ? JSON.parse(rsvpResponse.data.items_bringing) : []), + other_items: rsvpResponse.data.other_items || '' + }); + + // Process needed items (same logic as RSVPForm) + let items: string[] = []; + if (eventResponse.data.needed_items) { + items = Array.isArray(eventResponse.data.needed_items) + ? eventResponse.data.needed_items + : typeof eventResponse.data.needed_items === 'string' + ? JSON.parse(eventResponse.data.needed_items) + : []; + } + + // Get all claimed items from existing RSVPs (excluding the current one) + const claimed = new Set(); + if (Array.isArray(rsvpsResponse.data)) { + rsvpsResponse.data.forEach((rsvp: any) => { + if (rsvp.id !== rsvpResponse.data.id) { // Exclude current RSVP's claimed items initially + try { + let rsvpItems: string[] = []; + if (typeof rsvp.items_bringing === 'string') { + try { + const parsed = JSON.parse(rsvp.items_bringing); + rsvpItems = Array.isArray(parsed) ? parsed : []; + } catch (e) { + console.error('Error parsing items_bringing JSON:', e); + } + } else if (Array.isArray(rsvp.items_bringing)) { + rsvpItems = rsvp.items_bringing; + } + + if (Array.isArray(rsvpItems)) { + rsvpItems.forEach((item: string) => claimed.add(item)); + } + } catch (e) { + console.error('Error processing RSVP items:', e); + } + } + }); + } + + // Filter out claimed items from available items, but include items the current RSVP already claimed + const availableItems = items.filter(item => !claimed.has(item) || (Array.isArray(rsvpResponse.data.items_bringing) ? rsvpResponse.data.items_bringing.includes(item) : (typeof rsvpResponse.data.items_bringing === 'string' && rsvpResponse.data.items_bringing ? JSON.parse(rsvpResponse.data.items_bringing).includes(item) : false))); + + setNeededItems(availableItems); + setClaimedItems(Array.from(claimed)); // This will be claimed by others + setLoading(false); + + } catch (error) { + console.error('Error fetching RSVP details:', error); + setError('Failed to load RSVP details. The link may be invalid or expired.'); + setLoading(false); + } + }; + fetchRsvpDetails(); + }, [slug, editId]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + + if (name === 'attending') { + if (value === 'no' || value === 'maybe') { + setFormData(prev => ({ + ...prev, + [name]: value, + bringing_guests: 'no', + guest_count: 0, + guest_names: [], + items_bringing: [], + other_items: '' + })); + return; + } + } + + if (name === 'bringing_guests') { + if (value === 'no') { + setFormData(prev => ({ + ...prev, + [name]: value, + guest_count: 0, + guest_names: [] + })); + return; + } else if (value === 'yes') { + setFormData(prev => ({ + ...prev, + [name]: value, + guest_count: 1, + guest_names: [''] + })); + return; + } + } + + if (name === 'guest_count') { + const count = parseInt(value) || 0; + setFormData(prev => ({ + ...prev, + [name]: count, + guest_names: Array(count).fill('').map((_, i) => prev.guest_names[i] || '') + })); + return; + } + + if (name.startsWith('guest_name_')) { + const index = parseInt(name.split('_')[2]); + setFormData(prev => ({ + ...prev, + guest_names: prev.guest_names.map((name, i) => i === index ? value : name) + })); + return; + } + + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSelectChange = (e: SelectChangeEvent) => { + const { name, value } = e.target; + + if (name === 'attending' && value !== 'yes') { + setFormData(prev => ({ + ...prev, + attending: value as 'no' | 'maybe', + bringing_guests: 'no', + guest_count: 0, + guest_names: [], + items_bringing: [], + other_items: '' + })); + } else if (name === 'bringing_guests') { + setFormData(prev => { + const maxGuests = event?.max_guests_per_rsvp; + let initialGuestCount = 1; + + if (maxGuests === 0 && value === 'yes') { + return { + ...prev, + bringing_guests: 'no', + guest_count: 0, + guest_names: [] + }; + } + + if (maxGuests !== undefined && maxGuests !== -1 && maxGuests < initialGuestCount) { + initialGuestCount = maxGuests; + } + + return { + ...prev, + bringing_guests: value as 'yes' | 'no', + guest_count: value === 'yes' ? initialGuestCount : 0, + guest_names: value === 'no' ? [] : Array(initialGuestCount).fill('') + }; + }); + } else { + setFormData(prev => ({ + ...prev, + [name]: value + })); + } + }; + + const handleItemsChange = (e: SelectChangeEvent) => { + const { value } = e.target; + const itemsArray = Array.isArray(value) ? value : []; + setFormData(prev => ({ + ...prev, + items_bringing: itemsArray + })); + }; + + 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); + + if (!formData.name.trim() || !formData.attending) { + setError('Please fill in all required fields'); + setIsSubmitting(false); + return; + } + + if (formData.attending === 'yes' && !formData.bringing_guests) { + setError('Please indicate if you are bringing guests'); + setIsSubmitting(false); + return; + } + + if (formData.bringing_guests === 'yes' && + (formData.guest_count < 1 || + formData.guest_names.some(name => !name.trim()))) { + setError('Please provide names for all guests'); + setIsSubmitting(false); + return; + } + + try { + const splitOtherItems = formData.other_items + .split(/\r?\n|,/) + .map(s => s.trim()) + .filter(Boolean) + .join(', '); + const submissionData = { + ...formData, + items_bringing: formData.items_bringing, + other_items: splitOtherItems + }; + + // Use the new PUT endpoint for updating by editId + await axios.put(`/api/rsvps/edit/${editId}`, submissionData); + + setSuccess(true); + } catch (err) { + console.error('Error updating RSVP:', err); + setError('Failed to update RSVP. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + if (loading) { + return ( + + Loading... + + ); + } + + if (error || !event) { + return ( + + {error || 'Event or RSVP not found'} + + ); + } + + if (success) { + return ( + + + + + + Success! + + + Your RSVP has been updated successfully. + + + + + + + ); + } + + return ( + + + + + + Edit Your RSVP + + + {event?.rsvp_cutoff_date && ( + + Note: Submissions cannot be edited after {new Date(event.rsvp_cutoff_date).toLocaleString()} + + )} + + {isEventClosed && ( + + Event registration is closed. Changes are not allowed. Please contact the event organizer for assistance. + + )} + + {error && ( + + {error} + + )} + + + + + {/* 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 + + )} + + + + + + ); +}; + +export default RSVPEditForm; diff --git a/frontend/src/components/RSVPForm.tsx b/frontend/src/components/RSVPForm.tsx index 4182f1f..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'; @@ -29,6 +30,8 @@ interface RSVPFormData { guest_names: string[]; items_bringing: string[]; other_items: string; + send_email_confirmation: boolean; // New field for email opt-in + email_address: string; // New field for recipient email } const RSVPForm: React.FC = () => { @@ -40,7 +43,9 @@ const RSVPForm: React.FC = () => { guest_count: 1, guest_names: [], items_bringing: [], - other_items: '' + other_items: '', + send_email_confirmation: false, // Initialize to false + email_address: '' // Initialize to empty }); const [neededItems, setNeededItems] = useState([]); const [claimedItems, setClaimedItems] = useState([]); @@ -184,6 +189,14 @@ const RSVPForm: React.FC = () => { })); }; + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: checked + })); + }; + const handleSelectChange = (e: SelectChangeEvent) => { const { name, value } = e.target; @@ -196,18 +209,40 @@ const RSVPForm: React.FC = () => { guest_count: 0, guest_names: [], items_bringing: [], // Clear items when not attending - other_items: '' + other_items: '', + send_email_confirmation: false, // Also reset email opt-in + email_address: '' // And email address })); } 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, @@ -260,7 +295,9 @@ const RSVPForm: React.FC = () => { const submissionData = { ...formData, items_bringing: formData.items_bringing, - other_items: splitOtherItems + other_items: splitOtherItems, + send_email_confirmation: formData.send_email_confirmation, + email_address: formData.email_address.trim() }; const response = await axios.post(`/api/events/${slug}/rsvp`, submissionData); @@ -270,6 +307,14 @@ const RSVPForm: React.FC = () => { axios.get(`/api/events/${slug}/rsvps`) ]); + // Optionally display success message with edit link if email was sent + if (formData.send_email_confirmation && formData.email_address.trim()) { + // The backend sends the email, we just need to confirm success here + setSuccess(true); + } else { + setSuccess(true); // Still show success even if email wasn't sent + } + // Process needed items let items: string[] = []; if (eventResponse.data.needed_items) { @@ -465,19 +510,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) => ( { )} + {/* Email Notification Section */} + + + To receive a link to edit your submission later, please enable email notifications below. + + + } + label="Send me an email confirmation with an edit link" + /> + + {formData.send_email_confirmation && ( + + )} + +