// ============================================================= // Wedding Planner — Complete Backend (Node.js + Express) // Database: PostgreSQL via server // Auth: Session-based (login / register) // Email: Gmail via Nodemailer // ============================================================= // Load environment variables require('dotenv').config(); const express = require('express'); const { Pool } = require('pg'); const cors = require('cors'); const app = express(); app.use(cors({ origin: "http://localhost:3000", credentials: true })); app.use(express.json()); const bcrypt = require('bcryptjs'); const session = require('express-session'); const nodemailer = require('nodemailer'); const crypto = require('crypto'); const path = require('path'); const PORT = process.env.PORT || 3000; // ============================================================= // MIDDLEWARE // ============================================================= app.use(cors({ origin: 'http://localhost:3000', credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Serve all HTML files from the same folder as this server.js app.use(express.static(path.join(__dirname))); // Session setup app.use(session({ secret: 'wedding_planner_secret_key_2025', resave: false, saveUninitialized: false, cookie: { secure: false, // set true only if using HTTPS httpOnly: true, maxAge: 1000 * 60 * 60 * 8 // 8 hours } })); // ============================================================= // DATABASE CONNECTION // // ============================================================= const pool = new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_NAME, password: process.env.DB_PASSWORD, port: parseInt(process.env.DB_PORT, 10), ssl: { rejectUnauthorized: false } }); // POST /api/weddings/:wid/guests/bulk — add multiple guests in a transaction app.post('/api/weddings/:wid/guests/bulk', requireAuth, async (req, res) => { const { guests, sendEmails, bride_name, groom_name } = req.body; if (!Array.isArray(guests) || guests.length === 0) return res.status(400).json({ error: 'guests array is required.' }); const wid = req.params.wid; const client = await pool.connect(); try { // Ensure wedding exists const wRes = await client.query('SET search_path TO project; SELECT wedding_id, "date" FROM wedding WHERE wedding_id=$1', [wid]); if (wRes.rows.length === 0) { client.release(); return res.status(404).json({ error: 'Wedding not found.' }); } // Fetch events for this wedding const evRes = await client.query('SELECT event_id, event_type, "date", start_time, end_time FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time', [wid]); const events = evRes.rows; // Validate incoming data and ensure no duplicate guest names (both in payload and in DB) const providedNames = []; for (const g of guests) { const first_name = (g.first_name || '').trim(); const last_name = (g.last_name || '').trim(); if (!first_name || !last_name) { client.release(); return res.status(400).json({ error: 'Each guest must have first_name and last_name.' }); } providedNames.push(`${first_name.toLowerCase()} ${last_name.toLowerCase()}`); } // Check duplicates inside the uploaded CSV/payload const dupSet = providedNames.filter((v, i, a) => a.indexOf(v) !== i); if (dupSet.length > 0) { client.release(); return res.status(400).json({ error: 'Duplicate guest names in upload are not allowed.' }); } // Check duplicates already in DB for this wedding if (providedNames.length > 0) { // build parameter placeholders for names const placeholders = providedNames.map((_, i) => `$${i + 2}`).join(','); const params = [wid, ...providedNames]; const existing = await client.query( `SELECT first_name, last_name FROM project.guest WHERE wedding_id=$1 AND lower(first_name || ' ' || last_name) IN (${placeholders})`, params ); if (existing.rows.length > 0) { client.release(); return res.status(409).json({ error: 'One or more guests already exist for this wedding.' }); } } await client.query('BEGIN'); const inserted = []; for (const g of guests) { const first_name = (g.first_name || '').trim(); const last_name = (g.last_name || '').trim(); const email = (g.email || null); const role = (g.role || null); const ins = await client.query('INSERT INTO project.guest(first_name,last_name,email,wedding_id,role) VALUES ($1,$2,$3,$4,$5) RETURNING *', [first_name, last_name, email, wid, role]); const guest = ins.rows[0]; inserted.push(guest); // Create RSVP records for each event for (const ev of events) { try { await client.query( `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id) VALUES ('invited', CURRENT_DATE, $1, $2) ON CONFLICT (guest_id, event_id) DO NOTHING`, [guest.guest_id, ev.event_id] ); } catch (e) { // Log and continue console.warn('Could not create RSVP for guest', guest.guest_id, 'event', ev.event_id, e.message); } } } await client.query('COMMIT'); // Optionally send emails after commit — bride/groom names come from the frontend // (entered by the user in Settings, stored locally in their browser only). The // account owner's profile name is intentionally NOT used as a fallback anymore. if (sendEmails) { const bride = bride_name || 'the Bride'; const groom = groom_name || 'the Groom'; for (const guest of inserted) { if (!guest.email) continue; try { // Build event links const eventLinks = events.map(ev => { const token = crypto.createHmac('sha256', 'wedding_rsvp_secret').update(`${guest.guest_id}-${ev.event_id}`).digest('hex'); const base = `http://localhost:${PORT}`; return ` ${ev.event_type} — ${ev.date} ${ev.start_time}–${ev.end_time} ✅ Accept ❌ Decline `; }).join(''); const mailOptions = { from: `"Wedding Planner" <${process.env.EMAIL_USER}>`, to: guest.email, subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`, html: `
💍

You're Invited!

Wedding Invitation

Dear ${guest.first_name} ${guest.last_name},

Please RSVP for the events below:

${eventLinks}
` }; await transporter.sendMail(mailOptions); } catch (mailErr) { console.warn('Failed to send invite to', guest.email, mailErr.message); } } } client.release(); res.json({ inserted: inserted.length, guests: inserted }); } catch (err) { try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ } client.release(); console.error('Bulk guest insert error:', err.message); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); res.status(500).json({ error: 'Server error during bulk guest insert.' }); } }); console.log('📡 Attempting to connect to PostgreSQL...'); console.log(` Host: ${process.env.DB_HOST}:${process.env.DB_PORT}`); console.log(` Database: ${process.env.DB_NAME}`); pool.connect() .then(client => { console.log('✅ Connected to PostgreSQL database!'); client.release(); }) .catch(err => { console.error('❌ DB connection error:', err.message); console.error(' Make sure SSH tunnel is running on port 9999'); console.error(' SSH: ssh -L 9999:db_server:5432 t_wedding_planner2025@194.149.135.130'); console.error(' Full error:', err.stack); }); // Helper: always use the project schema const Q = (text, params) => pool.query(`SET search_path TO project; ${text}`, params); // ============================================================= // Service layer: Booking validation utilities // ============================================================= class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } } // Wrap pool.query to translate DB trigger exceptions into ApiError so endpoints return structured errors { const origQuery = pool.query.bind(pool); pool.query = async (text, params) => { try { return await origQuery(text, params); } catch (err) { // Postgres RAISE EXCEPTION yields SQLSTATE 'P0001' — translate to ApiError(409) if (err && err.code === 'P0001') { throw new ApiError(409, err.message); } throw err; } }; } const BookingService = { // Normalize date value (DATE from PG may be string or Date) _fmtDate(d) { if (!d) return null; if (d instanceof Date) return d.toISOString().slice(0,10); // assume string YYYY-MM-DD return String(d).slice(0,10); }, // Check if two time intervals overlap: [s1,e1) and [s2,e2) isOverlapping(s1, e1, s2, e2) { if (!s1 || !e1 || !s2 || !e2) return false; // times are strings like HH:MM:SS or HH:MM const toSec = t => { const parts = String(t).split(':').map(Number); return (parts[0]||0)*3600 + (parts[1]||0)*60 + (parts[2]||0); }; const a1 = toSec(s1), b1 = toSec(e1), a2 = toSec(s2), b2 = toSec(e2); return a1 < b2 && a2 < b1; }, // Gather all bookings/events for a given wedding and date async getBookingsForWeddingDate(wedding_id, date) { const dateOnly = BookingService._fmtDate(date); const q = ` SELECT 'venue' AS type, booking_id AS id, start_time, end_time FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date UNION ALL SELECT 'church' AS type, booking_id AS id, start_time, end_time FROM project.church_booking WHERE wedding_id=$1 AND "date" = $2::date UNION ALL SELECT 'registrar' AS type, booking_id AS id, start_time, end_time FROM project.registrar_booking WHERE wedding_id=$1 AND "date" = $2::date UNION ALL SELECT 'photographer' AS type, booking_id AS id, start_time, end_time FROM project.photographer_booking WHERE wedding_id=$1 AND "date" = $2::date UNION ALL SELECT 'band' AS type, booking_id AS id, start_time, end_time FROM project.band_booking WHERE wedding_id=$1 AND "date" = $2::date UNION ALL SELECT 'event' AS type, event_id AS id, start_time, end_time FROM project.event WHERE wedding_id=$1 AND "date" = $2::date `; const res = await pool.query(q, [wedding_id, dateOnly]); return res.rows; }, // Validate booking date equals wedding date async validateDateMatchesWedding(wedding_id, date) { // Use SQL date cast/comparison to avoid client-side format issues const dateOnly = BookingService._fmtDate(date); const r = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [wedding_id, dateOnly]); if (r.rows.length === 0) { // Determine whether wedding not found or date mismatch const exists = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1', [wedding_id]); if (exists.rows.length === 0) throw new ApiError(404, 'Wedding not found.'); throw new ApiError(400, 'Booking is only allowed on the wedding date.'); } }, // Validate time slot overlap across all bookings for the wedding/date // exclude optional { type, id } to allow updating existing record async validateNoOverlap(wedding_id, date, start_time, end_time, exclude) { const dateOnly = BookingService._fmtDate(date); const bookings = await BookingService.getBookingsForWeddingDate(wedding_id, dateOnly); for (const b of bookings) { if (exclude && String(exclude.type) === String(b.type) && String(exclude.id) === String(b.id)) continue; if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { throw new ApiError(409, 'This has been already booked, please change date or time to rebook'); } } }, // Registrar location must match wedding venue location for that wedding date // We require an existing venue booking on the wedding date and compare locations async validateRegistrarLocation(wedding_id, date, registrar_id) { // find registrar const r = await pool.query('SELECT location FROM project.registrar WHERE registrar_id=$1', [registrar_id]); if (r.rows.length === 0) throw new ApiError(404, 'Registrar not found.'); const regLoc = r.rows[0].location; // find venue booking(s) for the wedding on the date const dateOnly = BookingService._fmtDate(date); const vb = await pool.query('SELECT v.location FROM project.venue_booking vb JOIN project.venue v ON vb.venue_id=v.venue_id WHERE vb.wedding_id=$1 AND vb."date" = $2::date', [wedding_id, dateOnly]); if (vb.rows.length === 0) { // no venue booked on that date -> not allowed throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.'); } // require at least one matching venue location const match = vb.rows.some(x => String(x.location).trim().toLowerCase() === String(regLoc).trim().toLowerCase()); if (!match) throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.'); } , // Validate a resource (venue/church/registrar/band/photographer) is not already booked // exclude: optional { table: 'venue_booking'|'church_booking'|..., id: booking_id } async validateResourceAvailability(type, resourceId, date, start_time, end_time, exclude) { const mapping = { venue: { table: 'project.venue_booking', col: 'venue_id', idCol: 'booking_id' }, church: { table: 'project.church_booking', col: 'church_id', idCol: 'booking_id' }, registrar: { table: 'project.registrar_booking', col: 'registrar_id', idCol: 'booking_id' }, band: { table: 'project.band_booking', col: 'band_id', idCol: 'booking_id' }, photographer: { table: 'project.photographer_booking', col: 'photographer_id', idCol: 'booking_id' } }; const m = mapping[type]; if (!m) return; // nothing to validate // Build query to find overlapping bookings for the same resource regardless of wedding let q = `SELECT ${m.idCol} FROM ${m.table} WHERE ${m.col}=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4)`; const params = [resourceId, date, start_time, end_time]; if (exclude && exclude.id) { q += ` AND ${m.idCol} != $5`; params.push(exclude.id); } const r = await pool.query(q, params); if (r.rows.length > 0) throw new ApiError(409, 'This has been already booked, please change date or time to rebook'); } }; const BudgetService = { bookingHours(startTime, endTime) { const toSec = t => { const parts = String(t).split(':').map(Number); return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0); }; return Math.max(0, (toSec(endTime) - toSec(startTime)) / 3600); }, async calculateSummary(weddingId) { // Venue cost is calculated per confirmed venue booking as price_per_guest * number of accepted guests const venueRes = await pool.query( `SELECT COALESCE(SUM(v.price_per_guest * COALESCE(gc.confirmed_count,0)), 0) AS total FROM project.venue_booking vb JOIN project.venue v ON vb.venue_id = v.venue_id LEFT JOIN ( SELECT ev."date" AS ev_date, COUNT(DISTINCT er.guest_id) AS confirmed_count FROM project.event_rsvp er JOIN project.event ev ON er.event_id = ev.event_id WHERE er.status = 'accepted' AND ev.wedding_id = $1 GROUP BY ev."date" ) gc ON gc.ev_date = vb."date" WHERE vb.wedding_id = $1 AND vb.status = 'confirmed'`, [weddingId] ); const photoRes = await pool.query( `SELECT COALESCE(SUM( EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600 * p.price_per_hour ), 0) AS total FROM project.photographer_booking pb JOIN project.photographer p ON pb.photographer_id = p.photographer_id WHERE pb.wedding_id = $1 AND pb.status = 'confirmed'`, [weddingId] ); const bandRes = await pool.query( `SELECT COALESCE(SUM( EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600 * b.price_per_hour ), 0) AS total FROM project.band_booking bb JOIN project.band b ON bb.band_id = b.band_id WHERE bb.wedding_id = $1 AND bb.status = 'confirmed'`, [weddingId] ); const venueCost = Number(venueRes.rows[0].total || 0); const photographerCost = Number(photoRes.rows[0].total || 0); const bandCost = Number(bandRes.rows[0].total || 0); const totalCost = venueCost + photographerCost + bandCost; return { wedding_id: Number(weddingId), venue_cost: venueCost, photographer_cost: photographerCost, band_cost: bandCost, total_cost: totalCost }; }, async syncWeddingBudget(weddingId) { // Only recalculate — do NOT overwrite the user's predefined budget column return await BudgetService.calculateSummary(weddingId); }, // Returns predefined budget vs actual spending for a given wedding async getBudgetStatus(weddingId) { const [summary, wRow] = await Promise.all([ BudgetService.calculateSummary(weddingId), pool.query('SELECT budget FROM project.wedding WHERE wedding_id=$1', [weddingId]) ]); const predefined = Number((wRow.rows[0] || {}).budget || 0); const spent = summary.total_cost; const exceeded = predefined > 0 && spent > predefined; return { predefined_budget: predefined, total_cost: spent, exceeded, over_by: exceeded ? +(spent - predefined).toFixed(2) : 0, venue_cost: summary.venue_cost, photographer_cost: summary.photographer_cost, band_cost: summary.band_cost }; } }; // ============================================================= // EMAIL TRANSPORTER // ============================================================= const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } }); // ============================================================= // AUTH MIDDLEWARE — protect routes that require login // ============================================================= function requireAuth(req, res, next) { if (req.session && req.session.user) return next(); return res.status(401).json({ error: 'Not authenticated. Please log in.' }); } // ============================================================= // AUTH ROUTES // ============================================================= // POST /api/auth/register app.post('/api/auth/register', async (req, res) => { const { first_name, last_name, email, password, phone_number, gender, birthday } = req.body; if (!first_name || !last_name || !email || !password) return res.status(400).json({ error: 'First name, last name, email and password are required.' }); try { // Check email not already taken const exists = await pool.query( 'SELECT user_id FROM project."user" WHERE email = $1', [email] ); if (exists.rows.length > 0) return res.status(409).json({ error: 'Email already registered.' }); const hash = await bcrypt.hash(password, 10); const result = await pool.query( `INSERT INTO project."user"(first_name, last_name, email, password_hash, phone_number, gender, birthday) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING user_id, first_name, last_name, email, gender`, [first_name, last_name, email, hash, phone_number || null, gender || null, birthday || null] ); const user = result.rows[0]; req.session.user = user; res.status(201).json({ message: 'Registered successfully.', user }); } catch (err) { console.error('Register error:', err.message); res.status(500).json({ error: 'Server error during registration.' }); } }); // POST /api/auth/login app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password are required.' }); try { const result = await pool.query( 'SELECT * FROM project."user" WHERE email = $1', [email] ); if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid email or password.' }); const user = result.rows[0]; const match = await bcrypt.compare(password, user.password_hash); if (!match) return res.status(401).json({ error: 'Invalid email or password.' }); req.session.user = { user_id: user.user_id, first_name: user.first_name, last_name: user.last_name, email: user.email, gender: user.gender }; res.json({ message: 'Logged in.', user: req.session.user }); } catch (err) { console.error('Login error:', err.message); res.status(500).json({ error: 'Server error during login.' }); } }); // POST /api/auth/logout app.post('/api/auth/logout', (req, res) => { req.session.destroy(() => res.json({ message: 'Logged out.' })); }); // GET /api/auth/me — check current session app.get('/api/auth/me', (req, res) => { if (req.session && req.session.user) return res.json({ user: req.session.user }); res.status(401).json({ error: 'Not logged in.' }); }); // ============================================================= // USER / PROFILE // ============================================================= // GET /api/users/:id app.get('/api/users/:id', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT user_id, first_name, last_name, email, phone_number, gender, birthday FROM project."user" WHERE user_id = $1', [req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'User not found.' }); res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // PUT /api/users/:id — update profile app.put('/api/users/:id', requireAuth, async (req, res) => { const { first_name, last_name, email, phone_number, gender, birthday } = req.body; try { const result = await pool.query( `UPDATE project."user" SET first_name=$1, last_name=$2, email=$3, phone_number=$4, gender=$5, birthday=$6 WHERE user_id=$7 RETURNING user_id, first_name, last_name, email, phone_number, gender, birthday`, [first_name, last_name, email, phone_number, gender, birthday || null, req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'User not found.' }); // Update session too req.session.user = { ...req.session.user, ...result.rows[0] }; res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // WEDDINGS (UC0003) // ============================================================= // GET /api/weddings — get wedding(s) for logged-in user app.get('/api/weddings', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM project.wedding WHERE user_id = $1 ORDER BY date', [req.session.user.user_id] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:id app.get('/api/weddings/:id', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM project.wedding WHERE wedding_id = $1 AND user_id = $2', [req.params.id, req.session.user.user_id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' }); res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings — create wedding app.post('/api/weddings', requireAuth, async (req, res) => { const { date, budget, notes } = req.body; if (!date) return res.status(400).json({ error: 'Wedding date is required.' }); try { const result = await pool.query( 'INSERT INTO project.wedding("date", budget, notes, user_id) VALUES ($1,$2,$3,$4) RETURNING *', [date, budget ? Number(budget) : 0, notes || null, req.session.user.user_id] ); res.status(201).json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // PUT /api/weddings/:id app.put('/api/weddings/:id', requireAuth, async (req, res) => { const { date, budget, notes } = req.body; try { const result = await pool.query( `UPDATE project.wedding SET "date"=$1, budget=$2, notes=$3 WHERE wedding_id=$4 AND user_id=$5 RETURNING *`, [date, budget !== undefined ? Number(budget) : null, notes || null, req.params.id, req.session.user.user_id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' }); res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:id/budget — calculated from confirmed bookings vs predefined budget app.get('/api/weddings/:id/budget', requireAuth, async (req, res) => { try { const wedding = await pool.query( 'SELECT wedding_id FROM project.wedding WHERE wedding_id = $1 AND user_id = $2', [req.params.id, req.session.user.user_id] ); if (wedding.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' }); const status = await BudgetService.getBudgetStatus(req.params.id); res.json(status); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // DELETE /api/weddings/:id app.delete('/api/weddings/:id', requireAuth, async (req, res) => { try { await pool.query( 'DELETE FROM project.wedding WHERE wedding_id=$1 AND user_id=$2', [req.params.id, req.session.user.user_id] ); res.json({ message: 'Wedding deleted.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // EVENTS (UC0005) // ============================================================= // GET /api/weddings/:wid/events app.get('/api/weddings/:wid/events', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time', [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/events app.post('/api/weddings/:wid/events', requireAuth, async (req, res) => { const { event_type, date, start_time, end_time, status } = req.body; if (!event_type || !date || !start_time || !end_time) return res.status(400).json({ error: 'event_type, date, start_time, end_time are required.' }); try { // Business validations await BookingService.validateDateMatchesWedding(req.params.wid, date); await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); const result = await pool.query( `INSERT INTO project.event(event_type,"date",start_time,end_time,status,wedding_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [event_type, date, start_time, end_time, status || 'scheduled', req.params.wid] ); res.status(201).json(result.rows[0]); } catch (err) { console.error(err.message); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); // Postgres RAISE EXCEPTION uses SQLSTATE 'P0001' - surface message as conflict for the client if (err && (err.code === 'P0001' || String(err.message).toLowerCase().includes('already booked') || String(err.message).toLowerCase().includes('conflict'))) { return res.status(409).json({ error: err.message }); } res.status(500).json({ error: 'Server error.' }); } }); // PUT /api/events/:id app.put('/api/events/:id', requireAuth, async (req, res) => { const { event_type, date, start_time, end_time, status } = req.body; try { // fetch existing event to know wedding_id and current values const evRes = await pool.query('SELECT * FROM project.event WHERE event_id=$1', [req.params.id]); if (evRes.rows.length === 0) return res.status(404).json({ error: 'Event not found.' }); const ev = evRes.rows[0]; const weddingId = ev.wedding_id; // Validate date matches wedding and no overlap (exclude this event) await BookingService.validateDateMatchesWedding(weddingId, date); await BookingService.validateNoOverlap(weddingId, date, start_time, end_time, { type: 'event', id: req.params.id }); const result = await pool.query( `UPDATE project.event SET event_type=$1,"date"=$2,start_time=$3,end_time=$4,status=$5 WHERE event_id=$6 RETURNING *`, [event_type, date, start_time, end_time, status, req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Event not found.' }); res.json(result.rows[0]); } catch (err) { console.error(err.message); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && (err.code === 'P0001' || String(err.message).toLowerCase().includes('already booked') || String(err.message).toLowerCase().includes('conflict'))) { return res.status(409).json({ error: err.message }); } res.status(500).json({ error: 'Server error.' }); } }); // DELETE /api/events/:id app.delete('/api/events/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM project.event WHERE event_id=$1', [req.params.id]); res.json({ message: 'Event deleted.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // GUESTS (UC0004) // ============================================================= // GET /api/weddings/:wid/guests app.get('/api/weddings/:wid/guests', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT guest_id, first_name, last_name, email, role, wedding_id FROM project.guest WHERE wedding_id=$1 ORDER BY last_name, first_name', [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/guests — add guest + send email invite app.post('/api/weddings/:wid/guests', requireAuth, async (req, res) => { const { first_name, last_name, email, role, bride_name, groom_name } = req.body; if (!first_name || !last_name) return res.status(400).json({ error: 'First name and last name are required.' }); try { // Prevent duplicate guest name for the same wedding const dup = await pool.query( `SELECT guest_id FROM project.guest WHERE wedding_id=$1 AND lower(first_name || ' ' || last_name) = lower($2 || ' ' || $3)`, [req.params.wid, first_name, last_name] ); if (dup.rows.length > 0) return res.status(409).json({ error: 'Guest with the same name already exists for this wedding.' }); // Insert guest const result = await pool.query( 'INSERT INTO project.guest(first_name,last_name,email,wedding_id) VALUES ($1,$2,$3,$4) RETURNING *', [first_name, last_name, email || null, req.params.wid] ); const guest = result.rows[0]; // Fetch wedding info for the email (use account owner name as the couple) const weddingResult = await pool.query( `SELECT w."date", u.first_name AS owner_first, u.last_name AS owner_last FROM project.wedding w JOIN project."user" u ON w.user_id = u.user_id WHERE w.wedding_id = $1`, [req.params.wid] ); const wedding = weddingResult.rows[0] || {}; // Fetch all events for this wedding so guest can RSVP const eventsResult = await pool.query( 'SELECT * FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time', [req.params.wid] ); const events = eventsResult.rows; // Create initial RSVP records with status "invited" for each event for (const event of events) { try { await pool.query( `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id) VALUES ('invited', CURRENT_DATE, $1, $2) ON CONFLICT (guest_id, event_id) DO NOTHING`, [guest.guest_id, event.event_id] ); } catch (rsvpErr) { console.warn(`⚠️ Could not create RSVP record for guest ${guest.guest_id}, event ${event.event_id}:`, rsvpErr.message); } } // Send email invitation if email provided if (email && events.length > 0) { // Generate a secure token per event for the RSVP links const eventLinks = events.map(ev => { const token = crypto .createHmac('sha256', 'wedding_rsvp_secret') .update(`${guest.guest_id}-${ev.event_id}`) .digest('hex'); const base = `http://localhost:${PORT}`; return ` ${ev.event_type} — ${ev.date} ${ev.start_time}–${ev.end_time} ✅ Accept ❌ Decline `; }).join(''); // Bride/groom names are entered by the user in Settings (stored locally in their // browser only) and passed in from the frontend per-request. The account owner's // profile name is intentionally NOT used here anymore. const bride = bride_name || 'the Bride'; const groom = groom_name || 'the Groom'; const mailOptions = { from: `"Wedding Planner" <${process.env.EMAIL_USER}>`, to: email, subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`, html: `
💍

You're Invited!

Wedding of ${bride} & ${groom}

Dear ${first_name} ${last_name},

We are delighted to invite you to celebrate the wedding of ${bride} & ${groom} on ${wedding.date}.

Please RSVP for each event:

${eventLinks}

We look forward to celebrating with you. If you have any questions, please contact us directly.

Wedding Planner App · Sent with love 💌

` }; try { await transporter.sendMail(mailOptions); console.log(`📧 Invite sent to ${email}`); } catch (mailErr) { // Don't fail the guest insert if email fails — just log it console.warn('⚠️ Email send failed (guest still added):', mailErr.message); } } res.status(201).json({ guest, emailSent: !!(email && events.length > 0) }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // PUT /api/guests/:id app.put('/api/guests/:id', requireAuth, async (req, res) => { const { first_name, last_name, email, role } = req.body; try { const result = await pool.query( 'UPDATE project.guest SET first_name=$1,last_name=$2,email=$3,role=$4 WHERE guest_id=$5 RETURNING *', [first_name, last_name, email || null, role || 'Guest', req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Guest not found.' }); res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // DELETE /api/guests/:id app.delete('/api/guests/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM project.guest WHERE guest_id=$1', [req.params.id]); res.json({ message: 'Guest deleted.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // RSVP (UC0009) // ============================================================= // GET /api/rsvp?guest_id=&event_id= — check existing RSVP app.get('/api/rsvp', async (req, res) => { const { guest_id, event_id } = req.query; try { const result = await pool.query( 'SELECT * FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2', [guest_id, event_id] ); res.json(result.rows[0] || null); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/rsvp — guest submits RSVP (called from rsvp.html, no auth needed) app.post('/api/rsvp', async (req, res) => { const { guest_id, event_id, status, token } = req.body; if (!guest_id || !event_id || !status) return res.status(400).json({ error: 'guest_id, event_id and status are required.' }); // Verify token (protects against random submissions) const expected = crypto .createHmac('sha256', 'wedding_rsvp_secret') .update(`${guest_id}-${event_id}`) .digest('hex'); if (token !== expected) return res.status(403).json({ error: 'Invalid or expired RSVP link.' }); const validStatuses = ['accepted', 'declined', 'pending', 'invited']; if (!validStatuses.includes(status)) return res.status(400).json({ error: 'Status must be accepted, declined, pending, or invited.' }); try { // Upsert: update if exists, insert if not const result = await pool.query( `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id) VALUES ($1, CURRENT_DATE, $2, $3) ON CONFLICT (guest_id, event_id) DO UPDATE SET status=$1, response_date=CURRENT_DATE RETURNING *`, [status, guest_id, event_id] ); // Fetch guest + event info for the confirmation response const guestInfo = await pool.query( `SELECT g.first_name, g.last_name, e.event_type, e.date, e.start_time, e.end_time FROM project.guest g, project.event e WHERE g.guest_id=$1 AND e.event_id=$2`, [guest_id, event_id] ); // Sync budget when RSVP affects attendee counts const info = guestInfo.rows[0] || null; if (info && (status === 'accepted' || status === 'declined')) { try { // e.wedding_id is not selected above — fetch the event to get wedding_id const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]); if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id); } catch (e) { console.warn('Budget sync after RSVP failed:', e.message); } } // Ensure there is an attendance record for this guest/event so they appear in Seating (unassigned table) try { // Map RSVP status to attendance status used in seating let attendanceStatus = 'pending'; if (status === 'accepted') attendanceStatus = 'attending'; else if (status === 'declined') attendanceStatus = 'not_attending'; // Fetch guest role to store in attendance (fallback to 'Guest') const guestRoleRes = await pool.query('SELECT role FROM project.guest WHERE guest_id=$1', [guest_id]); const guestRole = guestRoleRes.rows[0]?.role || 'Guest'; // Upsert attendance but DO NOT overwrite table_number if already assigned await pool.query( `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id) VALUES ($1, NULL, $2, $3, $4) ON CONFLICT (guest_id, event_id) DO UPDATE SET status = $1, role = $2 `, [attendanceStatus, guestRole, guest_id, event_id] ); // If attendee accepted, ensure budget sync if (attendanceStatus === 'attending') { try { const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]); if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id); } catch (e) { console.warn('Budget sync after attendance upsert failed:', e.message); } } } catch (e) { console.warn('Could not ensure attendance record after RSVP:', e.message); } res.json({ rsvp: result.rows[0], guest: info }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/rsvp — all RSVPs for a wedding (for admin view) app.get('/api/weddings/:wid/rsvp', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT er.response_id, er.status, er.response_date, g.guest_id, g.first_name, g.last_name, g.email, e.event_id, e.event_type, e.date AS event_date FROM project.event_rsvp er JOIN project.guest g ON er.guest_id = g.guest_id JOIN project.event e ON er.event_id = e.event_id WHERE e.wedding_id = $1 ORDER BY e.date, g.last_name`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // ATTENDANCE / SEATING (UC0011) // ============================================================= // GET /api/weddings/:wid/attendance — full seating list for a wedding app.get('/api/weddings/:wid/attendance', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT a.attendance_id, a.status, a.table_number, a.role, g.guest_id, g.first_name, g.last_name, e.event_id, e.event_type FROM project.attendance a JOIN project.guest g ON a.guest_id = g.guest_id JOIN project.event e ON a.event_id = e.event_id WHERE e.wedding_id = $1 ORDER BY a.table_number NULLS LAST, g.last_name`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/events/:eid/attendance — seating for a specific event app.get('/api/events/:eid/attendance', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT a.attendance_id, a.status, a.table_number, a.role, g.guest_id, g.first_name, g.last_name FROM project.attendance a JOIN project.guest g ON a.guest_id = g.guest_id WHERE a.event_id = $1 ORDER BY a.table_number NULLS LAST, g.last_name`, [req.params.eid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/attendance — assign guest to event with table + role app.post('/api/attendance', requireAuth, async (req, res) => { const { table_number, role, guest_id, event_id } = req.body; if (!guest_id || !event_id || !role) return res.status(400).json({ error: 'guest_id, event_id and role are required.' }); try { // Fetch the guest's RSVP status for this event const rsvpResult = await pool.query( 'SELECT status FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2', [guest_id, event_id] ); const rsvpStatus = rsvpResult.rows[0]?.status || 'pending'; // Enforce table capacity limit (max 10 per table) if (table_number) { const cntRes = await pool.query( `SELECT COUNT(*) AS cnt FROM project.attendance WHERE event_id=$1 AND table_number=$2`, [event_id, table_number] ); const cnt = Number(cntRes.rows[0].cnt || 0); if (cnt >= 10) return res.status(400).json({ error: 'Table capacity exceeded (max 10 guests per table).' }); } const result = await pool.query( `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (guest_id, event_id) DO UPDATE SET status=$1, table_number=$2, role=$3 RETURNING *`, [rsvpStatus, table_number || null, role, guest_id, event_id] ); // Sync budget if this guest is accepted (affects venue per-guest cost) try { if (rsvpStatus === 'accepted') { const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]); if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id); } } catch (e) { console.warn('Budget sync after attendance change failed:', e.message); } res.status(201).json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // PUT /api/attendance/:id — update seating assignment app.put('/api/attendance/:id', requireAuth, async (req, res) => { const { table_number, role } = req.body; try { // Fetch the attendance record to get guest_id and event_id const attendanceRecord = await pool.query( 'SELECT guest_id, event_id FROM project.attendance WHERE attendance_id=$1', [req.params.id] ); if (attendanceRecord.rows.length === 0) return res.status(404).json({ error: 'Record not found.' }); const { guest_id, event_id } = attendanceRecord.rows[0]; // Fetch the guest's RSVP status for this event const rsvpResult = await pool.query( 'SELECT status FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2', [guest_id, event_id] ); const rsvpStatus = rsvpResult.rows[0]?.status || 'pending'; // Enforce table capacity (exclude current attendance record from the count) if (table_number) { const cntRes = await pool.query( `SELECT COUNT(*) AS cnt FROM project.attendance WHERE event_id=$1 AND table_number=$2 AND attendance_id != $3`, [event_id, table_number, req.params.id] ); const cnt = Number(cntRes.rows[0].cnt || 0); if (cnt >= 10) return res.status(400).json({ error: 'Table capacity exceeded (max 10 guests per table).' }); } const result = await pool.query( `UPDATE project.attendance SET status=$1, table_number=$2, role=$3 WHERE attendance_id=$4 RETURNING *`, [rsvpStatus, table_number || null, role, req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Record not found.' }); try { if (rsvpStatus === 'accepted') { const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]); if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id); } } catch (e) { console.warn('Budget sync after attendance update failed:', e.message); } res.json(result.rows[0]); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // DELETE /api/attendance/:id app.delete('/api/attendance/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM project.attendance WHERE attendance_id=$1', [req.params.id]); res.json({ message: 'Attendance record deleted.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // VENUES (UC0006) // ============================================================= // GET /api/venues — all venues with type name app.get('/api/venues', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT v.*, vt.type_name FROM project.venue v JOIN project.venue_type vt ON v.type_id = vt.type_id ORDER BY v.city, v.name` ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/venue-bookings app.get('/api/weddings/:wid/venue-bookings', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT vb.*, v.name AS venue_name, v.location, v.city FROM project.venue_booking vb JOIN project.venue v ON vb.venue_id = v.venue_id WHERE vb.wedding_id = $1 ORDER BY vb.date`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/venue-bookings app.post('/api/weddings/:wid/venue-bookings', requireAuth, async (req, res) => { const { date, start_time, end_time, status, price, venue_id } = req.body; if (!date || !start_time || !end_time || !venue_id) return res.status(400).json({ error: 'date, start_time, end_time and venue_id are required.' }); // Check availability in a transaction to avoid race conditions const client = await pool.connect(); try { console.log('BOOKING REQ venue:', { wedding_id: req.params.wid, date, start_time, end_time, venue_id }); await client.query('BEGIN'); const dateOnly = BookingService._fmtDate(date); console.log('Normalized dateOnly for venue booking:', dateOnly); // validate wedding date matches const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); } // check intra-wedding overlap const bookings = await client.query( `SELECT start_time, end_time FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date`, [req.params.wid, dateOnly] ); for (const b of bookings.rows) { if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } } // lock any existing bookings for this venue/date to prevent concurrent inserts const conflictLock = await client.query( `SELECT booking_id FROM project.venue_booking WHERE venue_id=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4) FOR UPDATE`, [venue_id, dateOnly, start_time, end_time] ); if (conflictLock.rows.length > 0) { console.log('CONFLICT detected for venue (locked rows):', conflictLock.rows); await client.query('ROLLBACK'); return res.status(409).json({ error: 'Venue is already booked during this time slot.' }); } // insert booking const ins = await client.query( `INSERT INTO project.venue_booking("date",start_time,end_time,status,price,venue_id,wedding_id) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [dateOnly, start_time, end_time, status || 'confirmed', price || 0, venue_id, req.params.wid] ); await client.query('COMMIT'); await BudgetService.syncWeddingBudget(req.params.wid); const _budget = await BudgetService.getBudgetStatus(req.params.wid); res.status(201).json({ ...ins.rows[0], _budget }); } catch (err) { try { await client.query('ROLLBACK'); } catch(e) { /* ignore */ } console.error('Venue booking error:', err && err.message ? err.message : err); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && err.code === 'P0001') return res.status(409).json({ error: err.message }); res.status(500).json({ error: 'Server error.' }); } finally { client.release(); } }); // DELETE /api/venue-bookings/:id app.delete('/api/venue-bookings/:id', requireAuth, async (req, res) => { try { const del = await pool.query( 'DELETE FROM project.venue_booking WHERE booking_id=$1 RETURNING wedding_id', [req.params.id] ); if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id); res.json({ message: 'Venue booking cancelled.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // BANDS (UC0007) // ============================================================= // GET /api/bands app.get('/api/bands', requireAuth, async (req, res) => { try { const result = await pool.query('SELECT * FROM project.band ORDER BY band_name'); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/band-bookings app.get('/api/weddings/:wid/band-bookings', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT bb.*, b.band_name, b.genre, b.price_per_hour, ROUND((EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600)::numeric, 2) AS hours, ROUND((EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600 * b.price_per_hour)::numeric, 2) AS booking_cost FROM project.band_booking bb JOIN project.band b ON bb.band_id = b.band_id WHERE bb.wedding_id=$1 ORDER BY bb.date`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/band-bookings app.post('/api/weddings/:wid/band-bookings', requireAuth, async (req, res) => { const { date, start_time, end_time, status, band_id } = req.body; if (!date || !start_time || !end_time || !band_id) return res.status(400).json({ error: 'date, start_time, end_time and band_id are required.' }); const client = await pool.connect(); try { console.log('BOOKING REQ band:', { wedding_id: req.params.wid, date, start_time, end_time, band_id }); await client.query('BEGIN'); const dateOnly = BookingService._fmtDate(date); console.log('Normalized dateOnly for band booking:', dateOnly); // Validate wedding date const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); } // Intra-wedding overlap const bookings = await client.query('SELECT start_time,end_time FROM project.band_booking WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); for (const b of bookings.rows) { if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } } // Lock overlapping bookings for this band/date across weddings const conflict = await client.query( `SELECT booking_id FROM project.band_booking WHERE band_id=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4) FOR UPDATE`, [band_id, dateOnly, start_time, end_time] ); if (conflict.rows.length > 0) { console.log('CONFLICT detected for band:', conflict.rows); await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } console.log('No conflict found for band — inserting booking'); const ins = await client.query( `INSERT INTO project.band_booking("date",start_time,end_time,status,band_id,wedding_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [dateOnly, start_time, end_time, status || 'confirmed', band_id, req.params.wid] ); console.log('Inserted band booking id:', ins.rows[0] && ins.rows[0].booking_id); await client.query('COMMIT'); await BudgetService.syncWeddingBudget(req.params.wid); const _budget = await BudgetService.getBudgetStatus(req.params.wid); res.status(201).json({ ...ins.rows[0], _budget }); } catch (err) { try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ } console.error('Band booking error:', err && err.message ? err.message : err); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && err.code === 'P0001') return res.status(409).json({ error: err.message }); res.status(500).json({ error: 'Server error.' }); } finally { client.release(); } }); // DELETE /api/band-bookings/:id app.delete('/api/band-bookings/:id', requireAuth, async (req, res) => { try { const del = await pool.query( 'DELETE FROM project.band_booking WHERE booking_id=$1 RETURNING wedding_id', [req.params.id] ); if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id); res.json({ message: 'Band booking removed.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // PHOTOGRAPHERS (UC0008) // ============================================================= // GET /api/photographers app.get('/api/photographers', requireAuth, async (req, res) => { try { const result = await pool.query('SELECT * FROM project.photographer ORDER BY name'); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/photographer-bookings app.get('/api/weddings/:wid/photographer-bookings', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT pb.*, p.name AS photographer_name, p.email AS photographer_email, p.price_per_hour, ROUND((EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600)::numeric, 2) AS hours, ROUND((EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600 * p.price_per_hour)::numeric, 2) AS booking_cost FROM project.photographer_booking pb JOIN project.photographer p ON pb.photographer_id = p.photographer_id WHERE pb.wedding_id=$1 ORDER BY pb.date`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/photographer-bookings app.post('/api/weddings/:wid/photographer-bookings', requireAuth, async (req, res) => { const { date, start_time, end_time, status, photographer_id } = req.body; if (!date || !start_time || !end_time || !photographer_id) return res.status(400).json({ error: 'date, start_time, end_time and photographer_id are required.' }); const client = await pool.connect(); try { console.log('BOOKING REQ photographer:', { wedding_id: req.params.wid, date, start_time, end_time, photographer_id }); await client.query('BEGIN'); const dateOnly = BookingService._fmtDate(date); console.log('Normalized dateOnly for photographer booking:', dateOnly); const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); } const bookings = await client.query('SELECT start_time,end_time FROM project.photographer_booking WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); for (const b of bookings.rows) { if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } } const conflict = await client.query( `SELECT booking_id FROM project.photographer_booking WHERE photographer_id=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4) FOR UPDATE`, [photographer_id, dateOnly, start_time, end_time] ); if (conflict.rows.length > 0) { console.log('CONFLICT detected for photographer:', conflict.rows); await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } console.log('No conflict found for photographer — inserting booking'); const ins = await client.query( `INSERT INTO project.photographer_booking("date",start_time,end_time,status,photographer_id,wedding_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [dateOnly, start_time, end_time, status || 'confirmed', photographer_id, req.params.wid] ); console.log('Inserted photographer booking id:', ins.rows[0] && ins.rows[0].booking_id); await client.query('COMMIT'); await BudgetService.syncWeddingBudget(req.params.wid); const _budget = await BudgetService.getBudgetStatus(req.params.wid); res.status(201).json({ ...ins.rows[0], _budget }); } catch (err) { try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ } console.error('Photographer booking error:', err && err.message ? err.message : err); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && err.code === 'P0001') return res.status(409).json({ error: err.message }); res.status(500).json({ error: 'Server error.' }); } finally { client.release(); } }); // DELETE /api/photographer-bookings/:id app.delete('/api/photographer-bookings/:id', requireAuth, async (req, res) => { try { const del = await pool.query( 'DELETE FROM project.photographer_booking WHERE booking_id=$1 RETURNING wedding_id', [req.params.id] ); if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id); res.json({ message: 'Photographer booking removed.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // CHURCHES + PRIESTS // ============================================================= // GET /api/churches — all churches with their linked priest app.get('/api/churches', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT c.*, p.name AS priest_name, p.contact AS priest_contact FROM project.church c LEFT JOIN project.priest p ON p.church_id = c.church_id ORDER BY c.name` ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/church-bookings app.get('/api/weddings/:wid/church-bookings', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT cb.*, c.name AS church_name, c.location, c.contact, p.name AS priest_name, p.contact AS priest_contact FROM project.church_booking cb JOIN project.church c ON cb.church_id = c.church_id LEFT JOIN project.priest p ON p.church_id = c.church_id WHERE cb.wedding_id=$1 ORDER BY cb.date`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/church-bookings app.post('/api/weddings/:wid/church-bookings', requireAuth, async (req, res) => { const { date, start_time, end_time, status, church_id } = req.body; if (!date || !start_time || !end_time || !church_id) return res.status(400).json({ error: 'date, start_time, end_time and church_id are required.' }); const client = await pool.connect(); try { console.log('BOOKING REQ church:', { wedding_id: req.params.wid, date, start_time, end_time, church_id }); await client.query('BEGIN'); const dateOnly = BookingService._fmtDate(date); console.log('Normalized dateOnly for church booking:', dateOnly); const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); } const bookings = await client.query('SELECT start_time,end_time FROM project.church_booking WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); for (const b of bookings.rows) { if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } } const conflict = await client.query( `SELECT booking_id FROM project.church_booking WHERE church_id=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4) FOR UPDATE`, [church_id, dateOnly, start_time, end_time] ); if (conflict.rows.length > 0) { console.log('CONFLICT detected for church:', conflict.rows); await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } console.log('No conflict found for church — inserting booking'); const ins = await client.query( `INSERT INTO project.church_booking("date",start_time,end_time,status,church_id,wedding_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [dateOnly, start_time, end_time, status || 'confirmed', church_id, req.params.wid] ); console.log('Inserted church booking id:', ins.rows[0] && ins.rows[0].booking_id); await client.query('COMMIT'); res.status(201).json(ins.rows[0]); } catch (err) { try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ } console.error('Church booking error:', err && err.message ? err.message : err); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && err.code === 'P0001') return res.status(409).json({ error: err.message }); res.status(500).json({ error: 'Server error.' }); } finally { client.release(); } }); // DELETE /api/church-bookings/:id app.delete('/api/church-bookings/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM project.church_booking WHERE booking_id=$1', [req.params.id]); res.json({ message: 'Church booking removed.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // REGISTRARS // ============================================================= // GET /api/registrars app.get('/api/registrars', requireAuth, async (req, res) => { try { const result = await pool.query('SELECT * FROM project.registrar ORDER BY name'); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // GET /api/weddings/:wid/registrar-bookings app.get('/api/weddings/:wid/registrar-bookings', requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT rb.*, r.name AS registrar_name, r.location FROM project.registrar_booking rb JOIN project.registrar r ON rb.registrar_id = r.registrar_id WHERE rb.wedding_id=$1 ORDER BY rb.date`, [req.params.wid] ); res.json(result.rows); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // POST /api/weddings/:wid/registrar-bookings app.post('/api/weddings/:wid/registrar-bookings', requireAuth, async (req, res) => { const { date, start_time, end_time, status, registrar_id } = req.body; if (!date || !start_time || !end_time || !registrar_id) return res.status(400).json({ error: 'date, start_time, end_time and registrar_id are required.' }); const client = await pool.connect(); try { console.log('BOOKING REQ registrar:', { wedding_id: req.params.wid, date, start_time, end_time, registrar_id }); await client.query('BEGIN'); const dateOnly = BookingService._fmtDate(date); console.log('Normalized dateOnly for registrar booking:', dateOnly); const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); } const bookings = await client.query('SELECT start_time,end_time FROM project.registrar_booking WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]); for (const b of bookings.rows) { if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } } const conflict = await client.query( `SELECT booking_id FROM project.registrar_booking WHERE registrar_id=$1 AND "date" = $2::date AND NOT (end_time <= $3 OR start_time >= $4) FOR UPDATE`, [registrar_id, dateOnly, start_time, end_time] ); if (conflict.rows.length > 0) { console.log('CONFLICT detected for registrar:', conflict.rows); await client.query('ROLLBACK'); return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' }); } await BookingService.validateRegistrarLocation(req.params.wid, dateOnly, registrar_id); console.log('No conflict found for registrar — inserting booking'); const ins = await client.query( `INSERT INTO project.registrar_booking("date",start_time,end_time,status,registrar_id,wedding_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [dateOnly, start_time, end_time, status || 'confirmed', registrar_id, req.params.wid] ); console.log('Inserted registrar booking id:', ins.rows[0] && ins.rows[0].booking_id); await client.query('COMMIT'); res.status(201).json(ins.rows[0]); } catch (err) { try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ } console.error('Registrar booking error:', err && err.message ? err.message : err); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err && err.code === 'P0001') return res.status(409).json({ error: err.message }); res.status(500).json({ error: 'Server error.' }); } finally { client.release(); } }); // DELETE /api/registrar-bookings/:id app.delete('/api/registrar-bookings/:id', requireAuth, async (req, res) => { try { await pool.query('DELETE FROM project.registrar_booking WHERE booking_id=$1', [req.params.id]); res.json({ message: 'Registrar booking removed.' }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Server error.' }); } }); // ============================================================= // START SERVER // ============================================================= app.listen(PORT, () => { console.log(`\n🌸 Wedding Planner server running at http://localhost:${PORT}`); console.log(` Dashboard → http://localhost:${PORT}/Wedding_Planner.html`); console.log(` Login → http://localhost:${PORT}/login.html`); console.log(` RSVP page → http://localhost:${PORT}/rsvp.html\n`); }); // Global error handler — convert ApiError and DB trigger errors into structured JSON responses app.use((err, req, res, next) => { console.error('Unhandled error:', err && err.stack ? err.stack : err); if (!err) return res.status(500).json({ error: 'Server error.' }); if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); if (err.code === 'P0001' || String(err.message || '').toLowerCase().includes('already booked') || String(err.message || '').toLowerCase().includes('conflict')) { return res.status(409).json({ error: err.message }); } return res.status(500).json({ error: 'Server error.' }); });