source: server.js

main
Last change on this file was 5b2dd1d, checked in by GitHub <noreply@…>, 3 weeks ago

Add files via upload

  • Property mode set to 100644
File size: 82.6 KB
Line 
1// =============================================================
2// Wedding Planner — Complete Backend (Node.js + Express)
3// Database: PostgreSQL via server
4// Auth: Session-based (login / register)
5// Email: Gmail via Nodemailer
6// =============================================================
7
8// Load environment variables
9require('dotenv').config();
10
11const express = require('express');
12const { Pool } = require('pg');
13const cors = require('cors');
14
15const app = express();
16
17app.use(cors({
18 origin: "http://localhost:3000",
19 credentials: true
20}));
21
22app.use(express.json());
23const bcrypt = require('bcryptjs');
24const session = require('express-session');
25const nodemailer = require('nodemailer');
26const crypto = require('crypto');
27const path = require('path');
28const PORT = process.env.PORT || 3000;
29
30// =============================================================
31// MIDDLEWARE
32// =============================================================
33app.use(cors({ origin: 'http://localhost:3000', credentials: true }));
34app.use(express.json());
35app.use(express.urlencoded({ extended: true }));
36
37// Serve all HTML files from the same folder as this server.js
38app.use(express.static(path.join(__dirname)));
39
40// Session setup
41app.use(session({
42 secret: 'wedding_planner_secret_key_2025',
43 resave: false,
44 saveUninitialized: false,
45 cookie: {
46 secure: false, // set true only if using HTTPS
47 httpOnly: true,
48 maxAge: 1000 * 60 * 60 * 8 // 8 hours
49 }
50}));
51
52// =============================================================
53// DATABASE CONNECTION
54//
55// =============================================================
56const pool = new Pool({
57 user: process.env.DB_USER,
58 host: process.env.DB_HOST,
59 database: process.env.DB_NAME,
60 password: process.env.DB_PASSWORD,
61 port: parseInt(process.env.DB_PORT, 10),
62 ssl: { rejectUnauthorized: false }
63});
64
65// POST /api/weddings/:wid/guests/bulk — add multiple guests in a transaction
66app.post('/api/weddings/:wid/guests/bulk', requireAuth, async (req, res) => {
67 const { guests, sendEmails, bride_name, groom_name } = req.body;
68 if (!Array.isArray(guests) || guests.length === 0) return res.status(400).json({ error: 'guests array is required.' });
69
70 const wid = req.params.wid;
71 const client = await pool.connect();
72 try {
73 // Ensure wedding exists
74 const wRes = await client.query('SET search_path TO project; SELECT wedding_id, "date" FROM wedding WHERE wedding_id=$1', [wid]);
75 if (wRes.rows.length === 0) {
76 client.release();
77 return res.status(404).json({ error: 'Wedding not found.' });
78 }
79
80 // Fetch events for this wedding
81 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]);
82 const events = evRes.rows;
83
84 // Validate incoming data and ensure no duplicate guest names (both in payload and in DB)
85 const providedNames = [];
86 for (const g of guests) {
87 const first_name = (g.first_name || '').trim();
88 const last_name = (g.last_name || '').trim();
89 if (!first_name || !last_name) {
90 client.release();
91 return res.status(400).json({ error: 'Each guest must have first_name and last_name.' });
92 }
93 providedNames.push(`${first_name.toLowerCase()} ${last_name.toLowerCase()}`);
94 }
95
96 // Check duplicates inside the uploaded CSV/payload
97 const dupSet = providedNames.filter((v, i, a) => a.indexOf(v) !== i);
98 if (dupSet.length > 0) { client.release(); return res.status(400).json({ error: 'Duplicate guest names in upload are not allowed.' }); }
99
100 // Check duplicates already in DB for this wedding
101 if (providedNames.length > 0) {
102 // build parameter placeholders for names
103 const placeholders = providedNames.map((_, i) => `$${i + 2}`).join(',');
104 const params = [wid, ...providedNames];
105 const existing = await client.query(
106 `SELECT first_name, last_name FROM project.guest WHERE wedding_id=$1 AND lower(first_name || ' ' || last_name) IN (${placeholders})`,
107 params
108 );
109 if (existing.rows.length > 0) {
110 client.release();
111 return res.status(409).json({ error: 'One or more guests already exist for this wedding.' });
112 }
113 }
114
115 await client.query('BEGIN');
116 const inserted = [];
117 for (const g of guests) {
118 const first_name = (g.first_name || '').trim();
119 const last_name = (g.last_name || '').trim();
120 const email = (g.email || null);
121 const role = (g.role || null);
122 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]);
123 const guest = ins.rows[0];
124 inserted.push(guest);
125
126 // Create RSVP records for each event
127 for (const ev of events) {
128 try {
129 await client.query(
130 `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id)
131 VALUES ('invited', CURRENT_DATE, $1, $2)
132 ON CONFLICT (guest_id, event_id) DO NOTHING`,
133 [guest.guest_id, ev.event_id]
134 );
135 } catch (e) {
136 // Log and continue
137 console.warn('Could not create RSVP for guest', guest.guest_id, 'event', ev.event_id, e.message);
138 }
139 }
140 }
141
142 await client.query('COMMIT');
143
144 // Optionally send emails after commit — bride/groom names come from the frontend
145 // (entered by the user in Settings, stored locally in their browser only). The
146 // account owner's profile name is intentionally NOT used as a fallback anymore.
147 if (sendEmails) {
148 const bride = bride_name || 'the Bride';
149 const groom = groom_name || 'the Groom';
150 for (const guest of inserted) {
151 if (!guest.email) continue;
152 try {
153 // Build event links
154 const eventLinks = events.map(ev => {
155 const token = crypto.createHmac('sha256', 'wedding_rsvp_secret').update(`${guest.guest_id}-${ev.event_id}`).digest('hex');
156 const base = `http://localhost:${PORT}`;
157 return `
158 <tr>
159 <td style="padding:8px 0; font-size:15px; color:#2c1f1f;">
160 <strong>${ev.event_type}</strong> — ${ev.date} ${ev.start_time}–${ev.end_time}
161 </td>
162 <td style="padding:8px 0; text-align:right;">
163 <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=accepted" style="background:#3a9e6b;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;margin-right:6px;font-size:13px;">✅ Accept</a>
164 <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=declined" style="background:#c94545;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;">❌ Decline</a>
165 </td>
166 </tr>`;
167 }).join('');
168
169 const mailOptions = {
170 from: `"Wedding Planner" <${process.env.EMAIL_USER}>`,
171 to: guest.email,
172 subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`,
173 html: `
174 <div style="font-family:'DM Sans',Arial,sans-serif;max-width:600px;margin:auto;background:#fdf8f3;border-radius:12px;overflow:hidden;border:1px solid #ecddd0;">
175 <div style="background:linear-gradient(135deg,#d4606a,#e8949c);padding:32px;text-align:center;">
176 <div style="font-size:36px;margin-bottom:8px;">💍</div>
177 <h1 style="color:#fff;font-size:26px;margin:0;">You're Invited!</h1>
178 <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding Invitation</p>
179 </div>
180 <div style="padding:32px;">
181 <p style="font-size:16px;color:#2c1f1f;">Dear <strong>${guest.first_name} ${guest.last_name}</strong>,</p>
182 <p style="font-size:15px;color:#6b4f4f;line-height:1.6;">Please RSVP for the events below:</p>
183 <table style="width:100%;border-collapse:collapse;">${eventLinks}</table>
184 </div>
185 <div style="background:#f2e8db;padding:16px;text-align:center;"></div>
186 </div>`
187 };
188 await transporter.sendMail(mailOptions);
189 } catch (mailErr) {
190 console.warn('Failed to send invite to', guest.email, mailErr.message);
191 }
192 }
193 }
194
195 client.release();
196 res.json({ inserted: inserted.length, guests: inserted });
197 } catch (err) {
198 try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
199 client.release();
200 console.error('Bulk guest insert error:', err.message);
201 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
202 res.status(500).json({ error: 'Server error during bulk guest insert.' });
203 }
204});
205
206console.log('📡 Attempting to connect to PostgreSQL...');
207console.log(` Host: ${process.env.DB_HOST}:${process.env.DB_PORT}`);
208console.log(` Database: ${process.env.DB_NAME}`);
209
210pool.connect()
211 .then(client => {
212 console.log('✅ Connected to PostgreSQL database!');
213 client.release();
214 })
215 .catch(err => {
216 console.error('❌ DB connection error:', err.message);
217 console.error(' Make sure SSH tunnel is running on port 9999');
218 console.error(' SSH: ssh -L 9999:db_server:5432 t_wedding_planner2025@194.149.135.130');
219 console.error(' Full error:', err.stack);
220 });
221
222// Helper: always use the project schema
223const Q = (text, params) => pool.query(`SET search_path TO project; ${text}`, params);
224
225// =============================================================
226// Service layer: Booking validation utilities
227// =============================================================
228class ApiError extends Error {
229 constructor(status, message) {
230 super(message);
231 this.status = status;
232 }
233}
234
235// Wrap pool.query to translate DB trigger exceptions into ApiError so endpoints return structured errors
236{
237 const origQuery = pool.query.bind(pool);
238 pool.query = async (text, params) => {
239 try {
240 return await origQuery(text, params);
241 } catch (err) {
242 // Postgres RAISE EXCEPTION yields SQLSTATE 'P0001' — translate to ApiError(409)
243 if (err && err.code === 'P0001') {
244 throw new ApiError(409, err.message);
245 }
246 throw err;
247 }
248 };
249}
250
251const BookingService = {
252 // Normalize date value (DATE from PG may be string or Date)
253 _fmtDate(d) {
254 if (!d) return null;
255 if (d instanceof Date) return d.toISOString().slice(0,10);
256 // assume string YYYY-MM-DD
257 return String(d).slice(0,10);
258 },
259
260 // Check if two time intervals overlap: [s1,e1) and [s2,e2)
261 isOverlapping(s1, e1, s2, e2) {
262 if (!s1 || !e1 || !s2 || !e2) return false;
263 // times are strings like HH:MM:SS or HH:MM
264 const toSec = t => {
265 const parts = String(t).split(':').map(Number);
266 return (parts[0]||0)*3600 + (parts[1]||0)*60 + (parts[2]||0);
267 };
268 const a1 = toSec(s1), b1 = toSec(e1), a2 = toSec(s2), b2 = toSec(e2);
269 return a1 < b2 && a2 < b1;
270 },
271
272 // Gather all bookings/events for a given wedding and date
273 async getBookingsForWeddingDate(wedding_id, date) {
274 const dateOnly = BookingService._fmtDate(date);
275 const q = `
276 SELECT 'venue' AS type, booking_id AS id, start_time, end_time
277 FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date
278 UNION ALL
279 SELECT 'church' AS type, booking_id AS id, start_time, end_time
280 FROM project.church_booking WHERE wedding_id=$1 AND "date" = $2::date
281 UNION ALL
282 SELECT 'registrar' AS type, booking_id AS id, start_time, end_time
283 FROM project.registrar_booking WHERE wedding_id=$1 AND "date" = $2::date
284 UNION ALL
285 SELECT 'photographer' AS type, booking_id AS id, start_time, end_time
286 FROM project.photographer_booking WHERE wedding_id=$1 AND "date" = $2::date
287 UNION ALL
288 SELECT 'band' AS type, booking_id AS id, start_time, end_time
289 FROM project.band_booking WHERE wedding_id=$1 AND "date" = $2::date
290 UNION ALL
291 SELECT 'event' AS type, event_id AS id, start_time, end_time
292 FROM project.event WHERE wedding_id=$1 AND "date" = $2::date
293 `;
294 const res = await pool.query(q, [wedding_id, dateOnly]);
295 return res.rows;
296 },
297
298 // Validate booking date equals wedding date
299 async validateDateMatchesWedding(wedding_id, date) {
300 // Use SQL date cast/comparison to avoid client-side format issues
301 const dateOnly = BookingService._fmtDate(date);
302 const r = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [wedding_id, dateOnly]);
303 if (r.rows.length === 0) {
304 // Determine whether wedding not found or date mismatch
305 const exists = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1', [wedding_id]);
306 if (exists.rows.length === 0) throw new ApiError(404, 'Wedding not found.');
307 throw new ApiError(400, 'Booking is only allowed on the wedding date.');
308 }
309 },
310
311 // Validate time slot overlap across all bookings for the wedding/date
312 // exclude optional { type, id } to allow updating existing record
313 async validateNoOverlap(wedding_id, date, start_time, end_time, exclude) {
314 const dateOnly = BookingService._fmtDate(date);
315 const bookings = await BookingService.getBookingsForWeddingDate(wedding_id, dateOnly);
316 for (const b of bookings) {
317 if (exclude && String(exclude.type) === String(b.type) && String(exclude.id) === String(b.id)) continue;
318 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
319 throw new ApiError(409, 'This has been already booked, please change date or time to rebook');
320 }
321 }
322 },
323
324 // Registrar location must match wedding venue location for that wedding date
325 // We require an existing venue booking on the wedding date and compare locations
326 async validateRegistrarLocation(wedding_id, date, registrar_id) {
327 // find registrar
328 const r = await pool.query('SELECT location FROM project.registrar WHERE registrar_id=$1', [registrar_id]);
329 if (r.rows.length === 0) throw new ApiError(404, 'Registrar not found.');
330 const regLoc = r.rows[0].location;
331
332 // find venue booking(s) for the wedding on the date
333 const dateOnly = BookingService._fmtDate(date);
334 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]);
335 if (vb.rows.length === 0) {
336 // no venue booked on that date -> not allowed
337 throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.');
338 }
339 // require at least one matching venue location
340 const match = vb.rows.some(x => String(x.location).trim().toLowerCase() === String(regLoc).trim().toLowerCase());
341 if (!match) throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.');
342 }
343 ,
344
345 // Validate a resource (venue/church/registrar/band/photographer) is not already booked
346 // exclude: optional { table: 'venue_booking'|'church_booking'|..., id: booking_id }
347 async validateResourceAvailability(type, resourceId, date, start_time, end_time, exclude) {
348 const mapping = {
349 venue: { table: 'project.venue_booking', col: 'venue_id', idCol: 'booking_id' },
350 church: { table: 'project.church_booking', col: 'church_id', idCol: 'booking_id' },
351 registrar: { table: 'project.registrar_booking', col: 'registrar_id', idCol: 'booking_id' },
352 band: { table: 'project.band_booking', col: 'band_id', idCol: 'booking_id' },
353 photographer: { table: 'project.photographer_booking', col: 'photographer_id', idCol: 'booking_id' }
354 };
355 const m = mapping[type];
356 if (!m) return; // nothing to validate
357 // Build query to find overlapping bookings for the same resource regardless of wedding
358 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)`;
359 const params = [resourceId, date, start_time, end_time];
360 if (exclude && exclude.id) { q += ` AND ${m.idCol} != $5`; params.push(exclude.id); }
361 const r = await pool.query(q, params);
362 if (r.rows.length > 0) throw new ApiError(409, 'This has been already booked, please change date or time to rebook');
363 }
364};
365
366const BudgetService = {
367 bookingHours(startTime, endTime) {
368 const toSec = t => {
369 const parts = String(t).split(':').map(Number);
370 return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0);
371 };
372 return Math.max(0, (toSec(endTime) - toSec(startTime)) / 3600);
373 },
374
375 async calculateSummary(weddingId) {
376 // Venue cost is calculated per confirmed venue booking as price_per_guest * number of accepted guests
377 const venueRes = await pool.query(
378 `SELECT COALESCE(SUM(v.price_per_guest * COALESCE(gc.confirmed_count,0)), 0) AS total
379 FROM project.venue_booking vb
380 JOIN project.venue v ON vb.venue_id = v.venue_id
381 LEFT JOIN (
382 SELECT ev."date" AS ev_date, COUNT(DISTINCT er.guest_id) AS confirmed_count
383 FROM project.event_rsvp er
384 JOIN project.event ev ON er.event_id = ev.event_id
385 WHERE er.status = 'accepted' AND ev.wedding_id = $1
386 GROUP BY ev."date"
387 ) gc ON gc.ev_date = vb."date"
388 WHERE vb.wedding_id = $1 AND vb.status = 'confirmed'`,
389 [weddingId]
390 );
391
392 const photoRes = await pool.query(
393 `SELECT COALESCE(SUM(
394 EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600 * p.price_per_hour
395 ), 0) AS total
396 FROM project.photographer_booking pb
397 JOIN project.photographer p ON pb.photographer_id = p.photographer_id
398 WHERE pb.wedding_id = $1 AND pb.status = 'confirmed'`,
399 [weddingId]
400 );
401
402 const bandRes = await pool.query(
403 `SELECT COALESCE(SUM(
404 EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600 * b.price_per_hour
405 ), 0) AS total
406 FROM project.band_booking bb
407 JOIN project.band b ON bb.band_id = b.band_id
408 WHERE bb.wedding_id = $1 AND bb.status = 'confirmed'`,
409 [weddingId]
410 );
411
412 const venueCost = Number(venueRes.rows[0].total || 0);
413 const photographerCost = Number(photoRes.rows[0].total || 0);
414 const bandCost = Number(bandRes.rows[0].total || 0);
415 const totalCost = venueCost + photographerCost + bandCost;
416
417 return {
418 wedding_id: Number(weddingId),
419 venue_cost: venueCost,
420 photographer_cost: photographerCost,
421 band_cost: bandCost,
422 total_cost: totalCost
423 };
424 },
425
426 async syncWeddingBudget(weddingId) {
427 // Only recalculate — do NOT overwrite the user's predefined budget column
428 return await BudgetService.calculateSummary(weddingId);
429 },
430
431 // Returns predefined budget vs actual spending for a given wedding
432 async getBudgetStatus(weddingId) {
433 const [summary, wRow] = await Promise.all([
434 BudgetService.calculateSummary(weddingId),
435 pool.query('SELECT budget FROM project.wedding WHERE wedding_id=$1', [weddingId])
436 ]);
437 const predefined = Number((wRow.rows[0] || {}).budget || 0);
438 const spent = summary.total_cost;
439 const exceeded = predefined > 0 && spent > predefined;
440 return {
441 predefined_budget: predefined,
442 total_cost: spent,
443 exceeded,
444 over_by: exceeded ? +(spent - predefined).toFixed(2) : 0,
445 venue_cost: summary.venue_cost,
446 photographer_cost: summary.photographer_cost,
447 band_cost: summary.band_cost
448 };
449 }
450};
451
452// =============================================================
453// EMAIL TRANSPORTER
454// =============================================================
455const transporter = nodemailer.createTransport({
456 service: 'gmail',
457 auth: {
458 user: process.env.EMAIL_USER,
459 pass: process.env.EMAIL_PASS
460 }
461});
462
463// =============================================================
464// AUTH MIDDLEWARE — protect routes that require login
465// =============================================================
466function requireAuth(req, res, next) {
467 if (req.session && req.session.user) return next();
468 return res.status(401).json({ error: 'Not authenticated. Please log in.' });
469}
470
471// =============================================================
472// AUTH ROUTES
473// =============================================================
474
475// POST /api/auth/register
476app.post('/api/auth/register', async (req, res) => {
477 const { first_name, last_name, email, password, phone_number, gender, birthday } = req.body;
478 if (!first_name || !last_name || !email || !password)
479 return res.status(400).json({ error: 'First name, last name, email and password are required.' });
480
481 try {
482 // Check email not already taken
483 const exists = await pool.query(
484 'SELECT user_id FROM project."user" WHERE email = $1', [email]
485 );
486 if (exists.rows.length > 0)
487 return res.status(409).json({ error: 'Email already registered.' });
488
489 const hash = await bcrypt.hash(password, 10);
490
491 const result = await pool.query(
492 `INSERT INTO project."user"(first_name, last_name, email, password_hash, phone_number, gender, birthday)
493 VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING user_id, first_name, last_name, email, gender`,
494 [first_name, last_name, email, hash, phone_number || null, gender || null, birthday || null]
495 );
496
497 const user = result.rows[0];
498 req.session.user = user;
499 res.status(201).json({ message: 'Registered successfully.', user });
500 } catch (err) {
501 console.error('Register error:', err.message);
502 res.status(500).json({ error: 'Server error during registration.' });
503 }
504});
505
506// POST /api/auth/login
507app.post('/api/auth/login', async (req, res) => {
508 const { email, password } = req.body;
509 if (!email || !password)
510 return res.status(400).json({ error: 'Email and password are required.' });
511
512 try {
513 const result = await pool.query(
514 'SELECT * FROM project."user" WHERE email = $1', [email]
515 );
516 if (result.rows.length === 0)
517 return res.status(401).json({ error: 'Invalid email or password.' });
518
519 const user = result.rows[0];
520 const match = await bcrypt.compare(password, user.password_hash);
521 if (!match)
522 return res.status(401).json({ error: 'Invalid email or password.' });
523
524 req.session.user = {
525 user_id: user.user_id,
526 first_name: user.first_name,
527 last_name: user.last_name,
528 email: user.email,
529 gender: user.gender
530 };
531 res.json({ message: 'Logged in.', user: req.session.user });
532 } catch (err) {
533 console.error('Login error:', err.message);
534 res.status(500).json({ error: 'Server error during login.' });
535 }
536});
537
538// POST /api/auth/logout
539app.post('/api/auth/logout', (req, res) => {
540 req.session.destroy(() => res.json({ message: 'Logged out.' }));
541});
542
543// GET /api/auth/me — check current session
544app.get('/api/auth/me', (req, res) => {
545 if (req.session && req.session.user)
546 return res.json({ user: req.session.user });
547 res.status(401).json({ error: 'Not logged in.' });
548});
549
550// =============================================================
551// USER / PROFILE
552// =============================================================
553
554// GET /api/users/:id
555app.get('/api/users/:id', requireAuth, async (req, res) => {
556 try {
557 const result = await pool.query(
558 'SELECT user_id, first_name, last_name, email, phone_number, gender, birthday FROM project."user" WHERE user_id = $1',
559 [req.params.id]
560 );
561 if (result.rows.length === 0) return res.status(404).json({ error: 'User not found.' });
562 res.json(result.rows[0]);
563 } catch (err) {
564 console.error(err.message);
565 res.status(500).json({ error: 'Server error.' });
566 }
567});
568
569// PUT /api/users/:id — update profile
570app.put('/api/users/:id', requireAuth, async (req, res) => {
571 const { first_name, last_name, email, phone_number, gender, birthday } = req.body;
572 try {
573 const result = await pool.query(
574 `UPDATE project."user"
575 SET first_name=$1, last_name=$2, email=$3, phone_number=$4, gender=$5, birthday=$6
576 WHERE user_id=$7 RETURNING user_id, first_name, last_name, email, phone_number, gender, birthday`,
577 [first_name, last_name, email, phone_number, gender, birthday || null, req.params.id]
578 );
579 if (result.rows.length === 0) return res.status(404).json({ error: 'User not found.' });
580 // Update session too
581 req.session.user = { ...req.session.user, ...result.rows[0] };
582 res.json(result.rows[0]);
583 } catch (err) {
584 console.error(err.message);
585 res.status(500).json({ error: 'Server error.' });
586 }
587});
588
589// =============================================================
590// WEDDINGS (UC0003)
591// =============================================================
592
593// GET /api/weddings — get wedding(s) for logged-in user
594app.get('/api/weddings', requireAuth, async (req, res) => {
595 try {
596 const result = await pool.query(
597 'SELECT * FROM project.wedding WHERE user_id = $1 ORDER BY date',
598 [req.session.user.user_id]
599 );
600 res.json(result.rows);
601 } catch (err) {
602 console.error(err.message);
603 res.status(500).json({ error: 'Server error.' });
604 }
605});
606
607// GET /api/weddings/:id
608app.get('/api/weddings/:id', requireAuth, async (req, res) => {
609 try {
610 const result = await pool.query(
611 'SELECT * FROM project.wedding WHERE wedding_id = $1 AND user_id = $2',
612 [req.params.id, req.session.user.user_id]
613 );
614 if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' });
615 res.json(result.rows[0]);
616 } catch (err) {
617 console.error(err.message);
618 res.status(500).json({ error: 'Server error.' });
619 }
620});
621
622// POST /api/weddings — create wedding
623app.post('/api/weddings', requireAuth, async (req, res) => {
624 const { date, budget, notes } = req.body;
625 if (!date) return res.status(400).json({ error: 'Wedding date is required.' });
626 try {
627 const result = await pool.query(
628 'INSERT INTO project.wedding("date", budget, notes, user_id) VALUES ($1,$2,$3,$4) RETURNING *',
629 [date, budget ? Number(budget) : 0, notes || null, req.session.user.user_id]
630 );
631 res.status(201).json(result.rows[0]);
632 } catch (err) {
633 console.error(err.message);
634 res.status(500).json({ error: 'Server error.' });
635 }
636});
637
638// PUT /api/weddings/:id
639app.put('/api/weddings/:id', requireAuth, async (req, res) => {
640 const { date, budget, notes } = req.body;
641 try {
642 const result = await pool.query(
643 `UPDATE project.wedding SET "date"=$1, budget=$2, notes=$3
644 WHERE wedding_id=$4 AND user_id=$5 RETURNING *`,
645 [date, budget !== undefined ? Number(budget) : null, notes || null, req.params.id, req.session.user.user_id]
646 );
647 if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' });
648 res.json(result.rows[0]);
649 } catch (err) {
650 console.error(err.message);
651 res.status(500).json({ error: 'Server error.' });
652 }
653});
654
655// GET /api/weddings/:id/budget — calculated from confirmed bookings vs predefined budget
656app.get('/api/weddings/:id/budget', requireAuth, async (req, res) => {
657 try {
658 const wedding = await pool.query(
659 'SELECT wedding_id FROM project.wedding WHERE wedding_id = $1 AND user_id = $2',
660 [req.params.id, req.session.user.user_id]
661 );
662 if (wedding.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' });
663 const status = await BudgetService.getBudgetStatus(req.params.id);
664 res.json(status);
665 } catch (err) {
666 console.error(err.message);
667 res.status(500).json({ error: 'Server error.' });
668 }
669});
670
671// DELETE /api/weddings/:id
672app.delete('/api/weddings/:id', requireAuth, async (req, res) => {
673 try {
674 await pool.query(
675 'DELETE FROM project.wedding WHERE wedding_id=$1 AND user_id=$2',
676 [req.params.id, req.session.user.user_id]
677 );
678 res.json({ message: 'Wedding deleted.' });
679 } catch (err) {
680 console.error(err.message);
681 res.status(500).json({ error: 'Server error.' });
682 }
683});
684
685// =============================================================
686// EVENTS (UC0005)
687// =============================================================
688
689// GET /api/weddings/:wid/events
690app.get('/api/weddings/:wid/events', requireAuth, async (req, res) => {
691 try {
692 const result = await pool.query(
693 'SELECT * FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time',
694 [req.params.wid]
695 );
696 res.json(result.rows);
697 } catch (err) {
698 console.error(err.message);
699 res.status(500).json({ error: 'Server error.' });
700 }
701});
702
703// POST /api/weddings/:wid/events
704app.post('/api/weddings/:wid/events', requireAuth, async (req, res) => {
705 const { event_type, date, start_time, end_time, status } = req.body;
706 if (!event_type || !date || !start_time || !end_time)
707 return res.status(400).json({ error: 'event_type, date, start_time, end_time are required.' });
708 try {
709 // Business validations
710 await BookingService.validateDateMatchesWedding(req.params.wid, date);
711 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null);
712
713 const result = await pool.query(
714 `INSERT INTO project.event(event_type,"date",start_time,end_time,status,wedding_id)
715 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
716 [event_type, date, start_time, end_time, status || 'scheduled', req.params.wid]
717 );
718 res.status(201).json(result.rows[0]);
719 } catch (err) {
720 console.error(err.message);
721 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
722 // Postgres RAISE EXCEPTION uses SQLSTATE 'P0001' - surface message as conflict for the client
723 if (err && (err.code === 'P0001' || String(err.message).toLowerCase().includes('already booked') || String(err.message).toLowerCase().includes('conflict'))) {
724 return res.status(409).json({ error: err.message });
725 }
726 res.status(500).json({ error: 'Server error.' });
727 }
728});
729
730// PUT /api/events/:id
731app.put('/api/events/:id', requireAuth, async (req, res) => {
732 const { event_type, date, start_time, end_time, status } = req.body;
733 try {
734 // fetch existing event to know wedding_id and current values
735 const evRes = await pool.query('SELECT * FROM project.event WHERE event_id=$1', [req.params.id]);
736 if (evRes.rows.length === 0) return res.status(404).json({ error: 'Event not found.' });
737 const ev = evRes.rows[0];
738 const weddingId = ev.wedding_id;
739 // Validate date matches wedding and no overlap (exclude this event)
740 await BookingService.validateDateMatchesWedding(weddingId, date);
741 await BookingService.validateNoOverlap(weddingId, date, start_time, end_time, { type: 'event', id: req.params.id });
742
743 const result = await pool.query(
744 `UPDATE project.event SET event_type=$1,"date"=$2,start_time=$3,end_time=$4,status=$5
745 WHERE event_id=$6 RETURNING *`,
746 [event_type, date, start_time, end_time, status, req.params.id]
747 );
748 if (result.rows.length === 0) return res.status(404).json({ error: 'Event not found.' });
749 res.json(result.rows[0]);
750 } catch (err) {
751 console.error(err.message);
752 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
753 if (err && (err.code === 'P0001' || String(err.message).toLowerCase().includes('already booked') || String(err.message).toLowerCase().includes('conflict'))) {
754 return res.status(409).json({ error: err.message });
755 }
756 res.status(500).json({ error: 'Server error.' });
757 }
758});
759
760// DELETE /api/events/:id
761app.delete('/api/events/:id', requireAuth, async (req, res) => {
762 try {
763 await pool.query('DELETE FROM project.event WHERE event_id=$1', [req.params.id]);
764 res.json({ message: 'Event deleted.' });
765 } catch (err) {
766 console.error(err.message);
767 res.status(500).json({ error: 'Server error.' });
768 }
769});
770
771// =============================================================
772// GUESTS (UC0004)
773// =============================================================
774
775// GET /api/weddings/:wid/guests
776app.get('/api/weddings/:wid/guests', requireAuth, async (req, res) => {
777 try {
778 const result = await pool.query(
779 'SELECT guest_id, first_name, last_name, email, role, wedding_id FROM project.guest WHERE wedding_id=$1 ORDER BY last_name, first_name',
780 [req.params.wid]
781 );
782 res.json(result.rows);
783 } catch (err) {
784 console.error(err.message);
785 res.status(500).json({ error: 'Server error.' });
786 }
787});
788
789// POST /api/weddings/:wid/guests — add guest + send email invite
790app.post('/api/weddings/:wid/guests', requireAuth, async (req, res) => {
791 const { first_name, last_name, email, role, bride_name, groom_name } = req.body;
792 if (!first_name || !last_name)
793 return res.status(400).json({ error: 'First name and last name are required.' });
794
795 try {
796 // Prevent duplicate guest name for the same wedding
797 const dup = await pool.query(
798 `SELECT guest_id FROM project.guest WHERE wedding_id=$1 AND lower(first_name || ' ' || last_name) = lower($2 || ' ' || $3)`,
799 [req.params.wid, first_name, last_name]
800 );
801 if (dup.rows.length > 0) return res.status(409).json({ error: 'Guest with the same name already exists for this wedding.' });
802
803 // Insert guest
804 const result = await pool.query(
805 'INSERT INTO project.guest(first_name,last_name,email,wedding_id) VALUES ($1,$2,$3,$4) RETURNING *',
806 [first_name, last_name, email || null, req.params.wid]
807 );
808 const guest = result.rows[0];
809
810 // Fetch wedding info for the email (use account owner name as the couple)
811 const weddingResult = await pool.query(
812 `SELECT w."date", u.first_name AS owner_first, u.last_name AS owner_last
813 FROM project.wedding w
814 JOIN project."user" u ON w.user_id = u.user_id
815 WHERE w.wedding_id = $1`,
816 [req.params.wid]
817 );
818 const wedding = weddingResult.rows[0] || {};
819
820 // Fetch all events for this wedding so guest can RSVP
821 const eventsResult = await pool.query(
822 'SELECT * FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time',
823 [req.params.wid]
824 );
825 const events = eventsResult.rows;
826
827 // Create initial RSVP records with status "invited" for each event
828 for (const event of events) {
829 try {
830 await pool.query(
831 `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id)
832 VALUES ('invited', CURRENT_DATE, $1, $2)
833 ON CONFLICT (guest_id, event_id) DO NOTHING`,
834 [guest.guest_id, event.event_id]
835 );
836 } catch (rsvpErr) {
837 console.warn(`⚠️ Could not create RSVP record for guest ${guest.guest_id}, event ${event.event_id}:`, rsvpErr.message);
838 }
839 }
840
841 // Send email invitation if email provided
842 if (email && events.length > 0) {
843 // Generate a secure token per event for the RSVP links
844 const eventLinks = events.map(ev => {
845 const token = crypto
846 .createHmac('sha256', 'wedding_rsvp_secret')
847 .update(`${guest.guest_id}-${ev.event_id}`)
848 .digest('hex');
849 const base = `http://localhost:${PORT}`;
850 return `
851 <tr>
852 <td style="padding:8px 0; font-size:15px; color:#2c1f1f;">
853 <strong>${ev.event_type}</strong> — ${ev.date} ${ev.start_time}–${ev.end_time}
854 </td>
855 <td style="padding:8px 0; text-align:right;">
856 <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=accepted"
857 style="background:#3a9e6b;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;margin-right:6px;font-size:13px;">
858 ✅ Accept
859 </a>
860 <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=declined"
861 style="background:#c94545;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;">
862 ❌ Decline
863 </a>
864 </td>
865 </tr>`;
866 }).join('');
867
868 // Bride/groom names are entered by the user in Settings (stored locally in their
869 // browser only) and passed in from the frontend per-request. The account owner's
870 // profile name is intentionally NOT used here anymore.
871 const bride = bride_name || 'the Bride';
872 const groom = groom_name || 'the Groom';
873
874 const mailOptions = {
875 from: `"Wedding Planner" <${process.env.EMAIL_USER}>`,
876 to: email,
877 subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`,
878 html: `
879 <div style="font-family:'DM Sans',Arial,sans-serif;max-width:600px;margin:auto;background:#fdf8f3;border-radius:12px;overflow:hidden;border:1px solid #ecddd0;">
880 <div style="background:linear-gradient(135deg,#d4606a,#e8949c);padding:32px;text-align:center;">
881 <div style="font-size:36px;margin-bottom:8px;">💍</div>
882 <h1 style="color:#fff;font-size:26px;margin:0;">You're Invited!</h1>
883 <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding of ${bride} &amp; ${groom}</p>
884 </div>
885 <div style="padding:32px;">
886 <p style="font-size:16px;color:#2c1f1f;">Dear <strong>${first_name} ${last_name}</strong>,</p>
887 <p style="font-size:15px;color:#6b4f4f;line-height:1.6;">
888 We are delighted to invite you to celebrate the wedding of
889 <strong>${bride} &amp; ${groom}</strong>
890 on <strong>${wedding.date}</strong>.
891 </p>
892 <p style="font-size:15px;color:#2c1f1f;font-weight:600;margin-top:24px;">Please RSVP for each event:</p>
893 <table style="width:100%;border-collapse:collapse;">
894 ${eventLinks}
895 </table>
896 <p style="font-size:13px;color:#a08080;margin-top:24px;">
897 We look forward to celebrating with you. If you have any questions, please contact us directly.
898 </p>
899 </div>
900 <div style="background:#f2e8db;padding:16px;text-align:center;">
901 <p style="font-size:12px;color:#a08080;margin:0;">Wedding Planner App · Sent with love 💌</p>
902 </div>
903 </div>`
904 };
905
906 try {
907 await transporter.sendMail(mailOptions);
908 console.log(`📧 Invite sent to ${email}`);
909 } catch (mailErr) {
910 // Don't fail the guest insert if email fails — just log it
911 console.warn('⚠️ Email send failed (guest still added):', mailErr.message);
912 }
913 }
914
915 res.status(201).json({ guest, emailSent: !!(email && events.length > 0) });
916 } catch (err) {
917 console.error(err.message);
918 res.status(500).json({ error: 'Server error.' });
919 }
920});
921
922// PUT /api/guests/:id
923app.put('/api/guests/:id', requireAuth, async (req, res) => {
924 const { first_name, last_name, email, role } = req.body;
925 try {
926 const result = await pool.query(
927 'UPDATE project.guest SET first_name=$1,last_name=$2,email=$3,role=$4 WHERE guest_id=$5 RETURNING *',
928 [first_name, last_name, email || null, role || 'Guest', req.params.id]
929 );
930 if (result.rows.length === 0) return res.status(404).json({ error: 'Guest not found.' });
931 res.json(result.rows[0]);
932 } catch (err) {
933 console.error(err.message);
934 res.status(500).json({ error: 'Server error.' });
935 }
936});
937
938// DELETE /api/guests/:id
939app.delete('/api/guests/:id', requireAuth, async (req, res) => {
940 try {
941 await pool.query('DELETE FROM project.guest WHERE guest_id=$1', [req.params.id]);
942 res.json({ message: 'Guest deleted.' });
943 } catch (err) {
944 console.error(err.message);
945 res.status(500).json({ error: 'Server error.' });
946 }
947});
948
949// =============================================================
950// RSVP (UC0009)
951// =============================================================
952
953// GET /api/rsvp?guest_id=&event_id= — check existing RSVP
954app.get('/api/rsvp', async (req, res) => {
955 const { guest_id, event_id } = req.query;
956 try {
957 const result = await pool.query(
958 'SELECT * FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2',
959 [guest_id, event_id]
960 );
961 res.json(result.rows[0] || null);
962 } catch (err) {
963 console.error(err.message);
964 res.status(500).json({ error: 'Server error.' });
965 }
966});
967
968// POST /api/rsvp — guest submits RSVP (called from rsvp.html, no auth needed)
969app.post('/api/rsvp', async (req, res) => {
970 const { guest_id, event_id, status, token } = req.body;
971 if (!guest_id || !event_id || !status)
972 return res.status(400).json({ error: 'guest_id, event_id and status are required.' });
973
974 // Verify token (protects against random submissions)
975 const expected = crypto
976 .createHmac('sha256', 'wedding_rsvp_secret')
977 .update(`${guest_id}-${event_id}`)
978 .digest('hex');
979 if (token !== expected)
980 return res.status(403).json({ error: 'Invalid or expired RSVP link.' });
981
982 const validStatuses = ['accepted', 'declined', 'pending', 'invited'];
983 if (!validStatuses.includes(status))
984 return res.status(400).json({ error: 'Status must be accepted, declined, pending, or invited.' });
985
986 try {
987 // Upsert: update if exists, insert if not
988 const result = await pool.query(
989 `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id)
990 VALUES ($1, CURRENT_DATE, $2, $3)
991 ON CONFLICT (guest_id, event_id)
992 DO UPDATE SET status=$1, response_date=CURRENT_DATE
993 RETURNING *`,
994 [status, guest_id, event_id]
995 );
996
997 // Fetch guest + event info for the confirmation response
998 const guestInfo = await pool.query(
999 `SELECT g.first_name, g.last_name, e.event_type, e.date, e.start_time, e.end_time
1000 FROM project.guest g, project.event e
1001 WHERE g.guest_id=$1 AND e.event_id=$2`,
1002 [guest_id, event_id]
1003 );
1004
1005 // Sync budget when RSVP affects attendee counts
1006 const info = guestInfo.rows[0] || null;
1007 if (info && (status === 'accepted' || status === 'declined')) {
1008 try {
1009 // e.wedding_id is not selected above — fetch the event to get wedding_id
1010 const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]);
1011 if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id);
1012 } catch (e) {
1013 console.warn('Budget sync after RSVP failed:', e.message);
1014 }
1015 }
1016
1017 // Ensure there is an attendance record for this guest/event so they appear in Seating (unassigned table)
1018 try {
1019 // Map RSVP status to attendance status used in seating
1020 let attendanceStatus = 'pending';
1021 if (status === 'accepted') attendanceStatus = 'attending';
1022 else if (status === 'declined') attendanceStatus = 'not_attending';
1023
1024 // Fetch guest role to store in attendance (fallback to 'Guest')
1025 const guestRoleRes = await pool.query('SELECT role FROM project.guest WHERE guest_id=$1', [guest_id]);
1026 const guestRole = guestRoleRes.rows[0]?.role || 'Guest';
1027
1028 // Upsert attendance but DO NOT overwrite table_number if already assigned
1029 await pool.query(
1030 `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id)
1031 VALUES ($1, NULL, $2, $3, $4)
1032 ON CONFLICT (guest_id, event_id)
1033 DO UPDATE SET status = $1, role = $2
1034 `,
1035 [attendanceStatus, guestRole, guest_id, event_id]
1036 );
1037
1038 // If attendee accepted, ensure budget sync
1039 if (attendanceStatus === 'attending') {
1040 try {
1041 const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]);
1042 if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id);
1043 } catch (e) { console.warn('Budget sync after attendance upsert failed:', e.message); }
1044 }
1045 } catch (e) {
1046 console.warn('Could not ensure attendance record after RSVP:', e.message);
1047 }
1048
1049 res.json({
1050 rsvp: result.rows[0],
1051 guest: info
1052 });
1053 } catch (err) {
1054 console.error(err.message);
1055 res.status(500).json({ error: 'Server error.' });
1056 }
1057});
1058
1059// GET /api/weddings/:wid/rsvp — all RSVPs for a wedding (for admin view)
1060app.get('/api/weddings/:wid/rsvp', requireAuth, async (req, res) => {
1061 try {
1062 const result = await pool.query(
1063 `SELECT er.response_id, er.status, er.response_date,
1064 g.guest_id, g.first_name, g.last_name, g.email,
1065 e.event_id, e.event_type, e.date AS event_date
1066 FROM project.event_rsvp er
1067 JOIN project.guest g ON er.guest_id = g.guest_id
1068 JOIN project.event e ON er.event_id = e.event_id
1069 WHERE e.wedding_id = $1
1070 ORDER BY e.date, g.last_name`,
1071 [req.params.wid]
1072 );
1073 res.json(result.rows);
1074 } catch (err) {
1075 console.error(err.message);
1076 res.status(500).json({ error: 'Server error.' });
1077 }
1078});
1079
1080// =============================================================
1081// ATTENDANCE / SEATING (UC0011)
1082// =============================================================
1083
1084// GET /api/weddings/:wid/attendance — full seating list for a wedding
1085app.get('/api/weddings/:wid/attendance', requireAuth, async (req, res) => {
1086 try {
1087 const result = await pool.query(
1088 `SELECT a.attendance_id, a.status, a.table_number, a.role,
1089 g.guest_id, g.first_name, g.last_name,
1090 e.event_id, e.event_type
1091 FROM project.attendance a
1092 JOIN project.guest g ON a.guest_id = g.guest_id
1093 JOIN project.event e ON a.event_id = e.event_id
1094 WHERE e.wedding_id = $1
1095 ORDER BY a.table_number NULLS LAST, g.last_name`,
1096 [req.params.wid]
1097 );
1098 res.json(result.rows);
1099 } catch (err) {
1100 console.error(err.message);
1101 res.status(500).json({ error: 'Server error.' });
1102 }
1103});
1104
1105// GET /api/events/:eid/attendance — seating for a specific event
1106app.get('/api/events/:eid/attendance', requireAuth, async (req, res) => {
1107 try {
1108 const result = await pool.query(
1109 `SELECT a.attendance_id, a.status, a.table_number, a.role,
1110 g.guest_id, g.first_name, g.last_name
1111 FROM project.attendance a
1112 JOIN project.guest g ON a.guest_id = g.guest_id
1113 WHERE a.event_id = $1
1114 ORDER BY a.table_number NULLS LAST, g.last_name`,
1115 [req.params.eid]
1116 );
1117 res.json(result.rows);
1118 } catch (err) {
1119 console.error(err.message);
1120 res.status(500).json({ error: 'Server error.' });
1121 }
1122});
1123
1124// POST /api/attendance — assign guest to event with table + role
1125app.post('/api/attendance', requireAuth, async (req, res) => {
1126 const { table_number, role, guest_id, event_id } = req.body;
1127 if (!guest_id || !event_id || !role)
1128 return res.status(400).json({ error: 'guest_id, event_id and role are required.' });
1129 try {
1130 // Fetch the guest's RSVP status for this event
1131 const rsvpResult = await pool.query(
1132 'SELECT status FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2',
1133 [guest_id, event_id]
1134 );
1135 const rsvpStatus = rsvpResult.rows[0]?.status || 'pending';
1136
1137 // Enforce table capacity limit (max 10 per table)
1138 if (table_number) {
1139 const cntRes = await pool.query(
1140 `SELECT COUNT(*) AS cnt FROM project.attendance WHERE event_id=$1 AND table_number=$2`,
1141 [event_id, table_number]
1142 );
1143 const cnt = Number(cntRes.rows[0].cnt || 0);
1144 if (cnt >= 10) return res.status(400).json({ error: 'Table capacity exceeded (max 10 guests per table).' });
1145 }
1146
1147 const result = await pool.query(
1148 `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id)
1149 VALUES ($1,$2,$3,$4,$5)
1150 ON CONFLICT (guest_id, event_id)
1151 DO UPDATE SET status=$1, table_number=$2, role=$3
1152 RETURNING *`,
1153 [rsvpStatus, table_number || null, role, guest_id, event_id]
1154 );
1155
1156 // Sync budget if this guest is accepted (affects venue per-guest cost)
1157 try {
1158 if (rsvpStatus === 'accepted') {
1159 const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]);
1160 if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id);
1161 }
1162 } catch (e) { console.warn('Budget sync after attendance change failed:', e.message); }
1163
1164 res.status(201).json(result.rows[0]);
1165 } catch (err) {
1166 console.error(err.message);
1167 res.status(500).json({ error: 'Server error.' });
1168 }
1169});
1170
1171// PUT /api/attendance/:id — update seating assignment
1172app.put('/api/attendance/:id', requireAuth, async (req, res) => {
1173 const { table_number, role } = req.body;
1174 try {
1175 // Fetch the attendance record to get guest_id and event_id
1176 const attendanceRecord = await pool.query(
1177 'SELECT guest_id, event_id FROM project.attendance WHERE attendance_id=$1',
1178 [req.params.id]
1179 );
1180 if (attendanceRecord.rows.length === 0)
1181 return res.status(404).json({ error: 'Record not found.' });
1182
1183 const { guest_id, event_id } = attendanceRecord.rows[0];
1184
1185 // Fetch the guest's RSVP status for this event
1186 const rsvpResult = await pool.query(
1187 'SELECT status FROM project.event_rsvp WHERE guest_id=$1 AND event_id=$2',
1188 [guest_id, event_id]
1189 );
1190 const rsvpStatus = rsvpResult.rows[0]?.status || 'pending';
1191
1192 // Enforce table capacity (exclude current attendance record from the count)
1193 if (table_number) {
1194 const cntRes = await pool.query(
1195 `SELECT COUNT(*) AS cnt FROM project.attendance WHERE event_id=$1 AND table_number=$2 AND attendance_id != $3`,
1196 [event_id, table_number, req.params.id]
1197 );
1198 const cnt = Number(cntRes.rows[0].cnt || 0);
1199 if (cnt >= 10) return res.status(400).json({ error: 'Table capacity exceeded (max 10 guests per table).' });
1200 }
1201
1202 const result = await pool.query(
1203 `UPDATE project.attendance SET status=$1, table_number=$2, role=$3
1204 WHERE attendance_id=$4 RETURNING *`,
1205 [rsvpStatus, table_number || null, role, req.params.id]
1206 );
1207 if (result.rows.length === 0) return res.status(404).json({ error: 'Record not found.' });
1208
1209 try {
1210 if (rsvpStatus === 'accepted') {
1211 const ev = await pool.query('SELECT wedding_id FROM project.event WHERE event_id=$1', [event_id]);
1212 if (ev.rows.length > 0) await BudgetService.syncWeddingBudget(ev.rows[0].wedding_id);
1213 }
1214 } catch (e) { console.warn('Budget sync after attendance update failed:', e.message); }
1215
1216 res.json(result.rows[0]);
1217 } catch (err) {
1218 console.error(err.message);
1219 res.status(500).json({ error: 'Server error.' });
1220 }
1221});
1222
1223// DELETE /api/attendance/:id
1224app.delete('/api/attendance/:id', requireAuth, async (req, res) => {
1225 try {
1226 await pool.query('DELETE FROM project.attendance WHERE attendance_id=$1', [req.params.id]);
1227 res.json({ message: 'Attendance record deleted.' });
1228 } catch (err) {
1229 console.error(err.message);
1230 res.status(500).json({ error: 'Server error.' });
1231 }
1232});
1233
1234// =============================================================
1235// VENUES (UC0006)
1236// =============================================================
1237
1238// GET /api/venues — all venues with type name
1239app.get('/api/venues', requireAuth, async (req, res) => {
1240 try {
1241 const result = await pool.query(
1242 `SELECT v.*, vt.type_name
1243 FROM project.venue v
1244 JOIN project.venue_type vt ON v.type_id = vt.type_id
1245 ORDER BY v.city, v.name`
1246 );
1247 res.json(result.rows);
1248 } catch (err) {
1249 console.error(err.message);
1250 res.status(500).json({ error: 'Server error.' });
1251 }
1252});
1253
1254// GET /api/weddings/:wid/venue-bookings
1255app.get('/api/weddings/:wid/venue-bookings', requireAuth, async (req, res) => {
1256 try {
1257 const result = await pool.query(
1258 `SELECT vb.*, v.name AS venue_name, v.location, v.city
1259 FROM project.venue_booking vb
1260 JOIN project.venue v ON vb.venue_id = v.venue_id
1261 WHERE vb.wedding_id = $1
1262 ORDER BY vb.date`,
1263 [req.params.wid]
1264 );
1265 res.json(result.rows);
1266 } catch (err) {
1267 console.error(err.message);
1268 res.status(500).json({ error: 'Server error.' });
1269 }
1270});
1271
1272// POST /api/weddings/:wid/venue-bookings
1273app.post('/api/weddings/:wid/venue-bookings', requireAuth, async (req, res) => {
1274 const { date, start_time, end_time, status, price, venue_id } = req.body;
1275 if (!date || !start_time || !end_time || !venue_id)
1276 return res.status(400).json({ error: 'date, start_time, end_time and venue_id are required.' });
1277
1278 // Check availability in a transaction to avoid race conditions
1279 const client = await pool.connect();
1280 try {
1281 console.log('BOOKING REQ venue:', { wedding_id: req.params.wid, date, start_time, end_time, venue_id });
1282 await client.query('BEGIN');
1283 const dateOnly = BookingService._fmtDate(date);
1284 console.log('Normalized dateOnly for venue booking:', dateOnly);
1285 // validate wedding date matches
1286 const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]);
1287 if (wRes.rows.length === 0) {
1288 await client.query('ROLLBACK');
1289 return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' });
1290 }
1291
1292 // check intra-wedding overlap
1293 const bookings = await client.query(
1294 `SELECT start_time, end_time FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date`,
1295 [req.params.wid, dateOnly]
1296 );
1297 for (const b of bookings.rows) {
1298 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
1299 await client.query('ROLLBACK');
1300 return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' });
1301 }
1302 }
1303
1304 // lock any existing bookings for this venue/date to prevent concurrent inserts
1305 const conflictLock = await client.query(
1306 `SELECT booking_id FROM project.venue_booking
1307 WHERE venue_id=$1 AND "date" = $2::date
1308 AND NOT (end_time <= $3 OR start_time >= $4)
1309 FOR UPDATE`,
1310 [venue_id, dateOnly, start_time, end_time]
1311 );
1312 if (conflictLock.rows.length > 0) {
1313 console.log('CONFLICT detected for venue (locked rows):', conflictLock.rows);
1314 await client.query('ROLLBACK');
1315 return res.status(409).json({ error: 'Venue is already booked during this time slot.' });
1316 }
1317
1318 // insert booking
1319 const ins = await client.query(
1320 `INSERT INTO project.venue_booking("date",start_time,end_time,status,price,venue_id,wedding_id)
1321 VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
1322 [dateOnly, start_time, end_time, status || 'confirmed', price || 0, venue_id, req.params.wid]
1323 );
1324
1325 await client.query('COMMIT');
1326 await BudgetService.syncWeddingBudget(req.params.wid);
1327 const _budget = await BudgetService.getBudgetStatus(req.params.wid);
1328 res.status(201).json({ ...ins.rows[0], _budget });
1329 } catch (err) {
1330 try { await client.query('ROLLBACK'); } catch(e) { /* ignore */ }
1331 console.error('Venue booking error:', err && err.message ? err.message : err);
1332 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1333 if (err && err.code === 'P0001') return res.status(409).json({ error: err.message });
1334 res.status(500).json({ error: 'Server error.' });
1335 } finally {
1336 client.release();
1337 }
1338});
1339
1340// DELETE /api/venue-bookings/:id
1341app.delete('/api/venue-bookings/:id', requireAuth, async (req, res) => {
1342 try {
1343 const del = await pool.query(
1344 'DELETE FROM project.venue_booking WHERE booking_id=$1 RETURNING wedding_id',
1345 [req.params.id]
1346 );
1347 if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id);
1348 res.json({ message: 'Venue booking cancelled.' });
1349 } catch (err) {
1350 console.error(err.message);
1351 res.status(500).json({ error: 'Server error.' });
1352 }
1353});
1354
1355// =============================================================
1356// BANDS (UC0007)
1357// =============================================================
1358
1359// GET /api/bands
1360app.get('/api/bands', requireAuth, async (req, res) => {
1361 try {
1362 const result = await pool.query('SELECT * FROM project.band ORDER BY band_name');
1363 res.json(result.rows);
1364 } catch (err) {
1365 console.error(err.message);
1366 res.status(500).json({ error: 'Server error.' });
1367 }
1368});
1369
1370// GET /api/weddings/:wid/band-bookings
1371app.get('/api/weddings/:wid/band-bookings', requireAuth, async (req, res) => {
1372 try {
1373 const result = await pool.query(
1374 `SELECT bb.*, b.band_name, b.genre, b.price_per_hour,
1375 ROUND((EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600)::numeric, 2) AS hours,
1376 ROUND((EXTRACT(EPOCH FROM (bb.end_time - bb.start_time)) / 3600 * b.price_per_hour)::numeric, 2) AS booking_cost
1377 FROM project.band_booking bb
1378 JOIN project.band b ON bb.band_id = b.band_id
1379 WHERE bb.wedding_id=$1 ORDER BY bb.date`,
1380 [req.params.wid]
1381 );
1382 res.json(result.rows);
1383 } catch (err) {
1384 console.error(err.message);
1385 res.status(500).json({ error: 'Server error.' });
1386 }
1387});
1388
1389// POST /api/weddings/:wid/band-bookings
1390app.post('/api/weddings/:wid/band-bookings', requireAuth, async (req, res) => {
1391 const { date, start_time, end_time, status, band_id } = req.body;
1392 if (!date || !start_time || !end_time || !band_id)
1393 return res.status(400).json({ error: 'date, start_time, end_time and band_id are required.' });
1394 const client = await pool.connect();
1395 try {
1396 console.log('BOOKING REQ band:', { wedding_id: req.params.wid, date, start_time, end_time, band_id });
1397 await client.query('BEGIN');
1398 const dateOnly = BookingService._fmtDate(date);
1399 console.log('Normalized dateOnly for band booking:', dateOnly);
1400 // Validate wedding date
1401 const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]);
1402 if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); }
1403
1404 // Intra-wedding overlap
1405 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]);
1406 for (const b of bookings.rows) {
1407 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
1408 await client.query('ROLLBACK');
1409 return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' });
1410 }
1411 }
1412
1413 // Lock overlapping bookings for this band/date across weddings
1414 const conflict = await client.query(
1415 `SELECT booking_id FROM project.band_booking
1416 WHERE band_id=$1 AND "date" = $2::date
1417 AND NOT (end_time <= $3 OR start_time >= $4)
1418 FOR UPDATE`,
1419 [band_id, dateOnly, start_time, end_time]
1420 );
1421 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' }); }
1422
1423 console.log('No conflict found for band — inserting booking');
1424 const ins = await client.query(
1425 `INSERT INTO project.band_booking("date",start_time,end_time,status,band_id,wedding_id)
1426 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
1427 [dateOnly, start_time, end_time, status || 'confirmed', band_id, req.params.wid]
1428 );
1429 console.log('Inserted band booking id:', ins.rows[0] && ins.rows[0].booking_id);
1430 await client.query('COMMIT');
1431 await BudgetService.syncWeddingBudget(req.params.wid);
1432 const _budget = await BudgetService.getBudgetStatus(req.params.wid);
1433 res.status(201).json({ ...ins.rows[0], _budget });
1434 } catch (err) {
1435 try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
1436 console.error('Band booking error:', err && err.message ? err.message : err);
1437 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1438 if (err && err.code === 'P0001') return res.status(409).json({ error: err.message });
1439 res.status(500).json({ error: 'Server error.' });
1440 } finally {
1441 client.release();
1442 }
1443});
1444
1445// DELETE /api/band-bookings/:id
1446app.delete('/api/band-bookings/:id', requireAuth, async (req, res) => {
1447 try {
1448 const del = await pool.query(
1449 'DELETE FROM project.band_booking WHERE booking_id=$1 RETURNING wedding_id',
1450 [req.params.id]
1451 );
1452 if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id);
1453 res.json({ message: 'Band booking removed.' });
1454 } catch (err) {
1455 console.error(err.message);
1456 res.status(500).json({ error: 'Server error.' });
1457 }
1458});
1459
1460// =============================================================
1461// PHOTOGRAPHERS (UC0008)
1462// =============================================================
1463
1464// GET /api/photographers
1465app.get('/api/photographers', requireAuth, async (req, res) => {
1466 try {
1467 const result = await pool.query('SELECT * FROM project.photographer ORDER BY name');
1468 res.json(result.rows);
1469 } catch (err) {
1470 console.error(err.message);
1471 res.status(500).json({ error: 'Server error.' });
1472 }
1473});
1474
1475// GET /api/weddings/:wid/photographer-bookings
1476app.get('/api/weddings/:wid/photographer-bookings', requireAuth, async (req, res) => {
1477 try {
1478 const result = await pool.query(
1479 `SELECT pb.*, p.name AS photographer_name, p.email AS photographer_email, p.price_per_hour,
1480 ROUND((EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600)::numeric, 2) AS hours,
1481 ROUND((EXTRACT(EPOCH FROM (pb.end_time - pb.start_time)) / 3600 * p.price_per_hour)::numeric, 2) AS booking_cost
1482 FROM project.photographer_booking pb
1483 JOIN project.photographer p ON pb.photographer_id = p.photographer_id
1484 WHERE pb.wedding_id=$1 ORDER BY pb.date`,
1485 [req.params.wid]
1486 );
1487 res.json(result.rows);
1488 } catch (err) {
1489 console.error(err.message);
1490 res.status(500).json({ error: 'Server error.' });
1491 }
1492});
1493
1494// POST /api/weddings/:wid/photographer-bookings
1495app.post('/api/weddings/:wid/photographer-bookings', requireAuth, async (req, res) => {
1496 const { date, start_time, end_time, status, photographer_id } = req.body;
1497 if (!date || !start_time || !end_time || !photographer_id)
1498 return res.status(400).json({ error: 'date, start_time, end_time and photographer_id are required.' });
1499 const client = await pool.connect();
1500 try {
1501 console.log('BOOKING REQ photographer:', { wedding_id: req.params.wid, date, start_time, end_time, photographer_id });
1502 await client.query('BEGIN');
1503 const dateOnly = BookingService._fmtDate(date);
1504 console.log('Normalized dateOnly for photographer booking:', dateOnly);
1505 const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]);
1506 if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); }
1507
1508 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]);
1509 for (const b of bookings.rows) {
1510 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
1511 await client.query('ROLLBACK');
1512 return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' });
1513 }
1514 }
1515
1516 const conflict = await client.query(
1517 `SELECT booking_id FROM project.photographer_booking
1518 WHERE photographer_id=$1 AND "date" = $2::date
1519 AND NOT (end_time <= $3 OR start_time >= $4)
1520 FOR UPDATE`,
1521 [photographer_id, dateOnly, start_time, end_time]
1522 );
1523
1524 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' }); }
1525
1526 console.log('No conflict found for photographer — inserting booking');
1527 const ins = await client.query(
1528 `INSERT INTO project.photographer_booking("date",start_time,end_time,status,photographer_id,wedding_id)
1529 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
1530 [dateOnly, start_time, end_time, status || 'confirmed', photographer_id, req.params.wid]
1531 );
1532 console.log('Inserted photographer booking id:', ins.rows[0] && ins.rows[0].booking_id);
1533 await client.query('COMMIT');
1534 await BudgetService.syncWeddingBudget(req.params.wid);
1535 const _budget = await BudgetService.getBudgetStatus(req.params.wid);
1536 res.status(201).json({ ...ins.rows[0], _budget });
1537 } catch (err) {
1538 try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
1539 console.error('Photographer booking error:', err && err.message ? err.message : err);
1540 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1541 if (err && err.code === 'P0001') return res.status(409).json({ error: err.message });
1542 res.status(500).json({ error: 'Server error.' });
1543 } finally {
1544 client.release();
1545 }
1546});
1547
1548// DELETE /api/photographer-bookings/:id
1549app.delete('/api/photographer-bookings/:id', requireAuth, async (req, res) => {
1550 try {
1551 const del = await pool.query(
1552 'DELETE FROM project.photographer_booking WHERE booking_id=$1 RETURNING wedding_id',
1553 [req.params.id]
1554 );
1555 if (del.rows.length > 0) await BudgetService.syncWeddingBudget(del.rows[0].wedding_id);
1556 res.json({ message: 'Photographer booking removed.' });
1557 } catch (err) {
1558 console.error(err.message);
1559 res.status(500).json({ error: 'Server error.' });
1560 }
1561});
1562
1563// =============================================================
1564// CHURCHES + PRIESTS
1565// =============================================================
1566
1567// GET /api/churches — all churches with their linked priest
1568app.get('/api/churches', requireAuth, async (req, res) => {
1569 try {
1570 const result = await pool.query(
1571 `SELECT c.*, p.name AS priest_name, p.contact AS priest_contact
1572 FROM project.church c
1573 LEFT JOIN project.priest p ON p.church_id = c.church_id
1574 ORDER BY c.name`
1575 );
1576 res.json(result.rows);
1577 } catch (err) {
1578 console.error(err.message);
1579 res.status(500).json({ error: 'Server error.' });
1580 }
1581});
1582
1583// GET /api/weddings/:wid/church-bookings
1584app.get('/api/weddings/:wid/church-bookings', requireAuth, async (req, res) => {
1585 try {
1586 const result = await pool.query(
1587 `SELECT cb.*, c.name AS church_name, c.location, c.contact,
1588 p.name AS priest_name, p.contact AS priest_contact
1589 FROM project.church_booking cb
1590 JOIN project.church c ON cb.church_id = c.church_id
1591 LEFT JOIN project.priest p ON p.church_id = c.church_id
1592 WHERE cb.wedding_id=$1 ORDER BY cb.date`,
1593 [req.params.wid]
1594 );
1595 res.json(result.rows);
1596 } catch (err) {
1597 console.error(err.message);
1598 res.status(500).json({ error: 'Server error.' });
1599 }
1600});
1601
1602// POST /api/weddings/:wid/church-bookings
1603app.post('/api/weddings/:wid/church-bookings', requireAuth, async (req, res) => {
1604 const { date, start_time, end_time, status, church_id } = req.body;
1605 if (!date || !start_time || !end_time || !church_id)
1606 return res.status(400).json({ error: 'date, start_time, end_time and church_id are required.' });
1607 const client = await pool.connect();
1608 try {
1609 console.log('BOOKING REQ church:', { wedding_id: req.params.wid, date, start_time, end_time, church_id });
1610 await client.query('BEGIN');
1611 const dateOnly = BookingService._fmtDate(date);
1612 console.log('Normalized dateOnly for church booking:', dateOnly);
1613 const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]);
1614 if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); }
1615
1616 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]);
1617 for (const b of bookings.rows) {
1618 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
1619 await client.query('ROLLBACK');
1620 return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' });
1621 }
1622 }
1623
1624 const conflict = await client.query(
1625 `SELECT booking_id FROM project.church_booking
1626 WHERE church_id=$1 AND "date" = $2::date
1627 AND NOT (end_time <= $3 OR start_time >= $4)
1628 FOR UPDATE`,
1629 [church_id, dateOnly, start_time, end_time]
1630 );
1631
1632 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' }); }
1633
1634 console.log('No conflict found for church — inserting booking');
1635 const ins = await client.query(
1636 `INSERT INTO project.church_booking("date",start_time,end_time,status,church_id,wedding_id)
1637 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
1638 [dateOnly, start_time, end_time, status || 'confirmed', church_id, req.params.wid]
1639 );
1640 console.log('Inserted church booking id:', ins.rows[0] && ins.rows[0].booking_id);
1641 await client.query('COMMIT');
1642 res.status(201).json(ins.rows[0]);
1643 } catch (err) {
1644 try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
1645 console.error('Church booking error:', err && err.message ? err.message : err);
1646 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1647 if (err && err.code === 'P0001') return res.status(409).json({ error: err.message });
1648 res.status(500).json({ error: 'Server error.' });
1649 } finally {
1650 client.release();
1651 }
1652});
1653
1654// DELETE /api/church-bookings/:id
1655app.delete('/api/church-bookings/:id', requireAuth, async (req, res) => {
1656 try {
1657 await pool.query('DELETE FROM project.church_booking WHERE booking_id=$1', [req.params.id]);
1658 res.json({ message: 'Church booking removed.' });
1659 } catch (err) {
1660 console.error(err.message);
1661 res.status(500).json({ error: 'Server error.' });
1662 }
1663});
1664
1665// =============================================================
1666// REGISTRARS
1667// =============================================================
1668
1669// GET /api/registrars
1670app.get('/api/registrars', requireAuth, async (req, res) => {
1671 try {
1672 const result = await pool.query('SELECT * FROM project.registrar ORDER BY name');
1673 res.json(result.rows);
1674 } catch (err) {
1675 console.error(err.message);
1676 res.status(500).json({ error: 'Server error.' });
1677 }
1678});
1679
1680// GET /api/weddings/:wid/registrar-bookings
1681app.get('/api/weddings/:wid/registrar-bookings', requireAuth, async (req, res) => {
1682 try {
1683 const result = await pool.query(
1684 `SELECT rb.*, r.name AS registrar_name, r.location
1685 FROM project.registrar_booking rb
1686 JOIN project.registrar r ON rb.registrar_id = r.registrar_id
1687 WHERE rb.wedding_id=$1 ORDER BY rb.date`,
1688 [req.params.wid]
1689 );
1690 res.json(result.rows);
1691 } catch (err) {
1692 console.error(err.message);
1693 res.status(500).json({ error: 'Server error.' });
1694 }
1695});
1696
1697// POST /api/weddings/:wid/registrar-bookings
1698app.post('/api/weddings/:wid/registrar-bookings', requireAuth, async (req, res) => {
1699 const { date, start_time, end_time, status, registrar_id } = req.body;
1700 if (!date || !start_time || !end_time || !registrar_id)
1701 return res.status(400).json({ error: 'date, start_time, end_time and registrar_id are required.' });
1702 const client = await pool.connect();
1703 try {
1704 console.log('BOOKING REQ registrar:', { wedding_id: req.params.wid, date, start_time, end_time, registrar_id });
1705 await client.query('BEGIN');
1706 const dateOnly = BookingService._fmtDate(date);
1707 console.log('Normalized dateOnly for registrar booking:', dateOnly);
1708 const wRes = await client.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [req.params.wid, dateOnly]);
1709 if (wRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Booking is only allowed on the wedding date.' }); }
1710
1711 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]);
1712 for (const b of bookings.rows) {
1713 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
1714 await client.query('ROLLBACK');
1715 return res.status(409).json({ error: 'This has been already booked, please change date or time to rebook' });
1716 }
1717 }
1718
1719 const conflict = await client.query(
1720 `SELECT booking_id FROM project.registrar_booking
1721 WHERE registrar_id=$1 AND "date" = $2::date
1722 AND NOT (end_time <= $3 OR start_time >= $4)
1723 FOR UPDATE`,
1724 [registrar_id, dateOnly, start_time, end_time]
1725 );
1726 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' }); }
1727
1728 await BookingService.validateRegistrarLocation(req.params.wid, dateOnly, registrar_id);
1729
1730 console.log('No conflict found for registrar — inserting booking');
1731 const ins = await client.query(
1732 `INSERT INTO project.registrar_booking("date",start_time,end_time,status,registrar_id,wedding_id)
1733 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
1734 [dateOnly, start_time, end_time, status || 'confirmed', registrar_id, req.params.wid]
1735 );
1736 console.log('Inserted registrar booking id:', ins.rows[0] && ins.rows[0].booking_id);
1737 await client.query('COMMIT');
1738 res.status(201).json(ins.rows[0]);
1739 } catch (err) {
1740 try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
1741 console.error('Registrar booking error:', err && err.message ? err.message : err);
1742 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1743 if (err && err.code === 'P0001') return res.status(409).json({ error: err.message });
1744 res.status(500).json({ error: 'Server error.' });
1745 } finally {
1746 client.release();
1747 }
1748});
1749
1750// DELETE /api/registrar-bookings/:id
1751app.delete('/api/registrar-bookings/:id', requireAuth, async (req, res) => {
1752 try {
1753 await pool.query('DELETE FROM project.registrar_booking WHERE booking_id=$1', [req.params.id]);
1754 res.json({ message: 'Registrar booking removed.' });
1755 } catch (err) {
1756 console.error(err.message);
1757 res.status(500).json({ error: 'Server error.' });
1758 }
1759});
1760
1761// =============================================================
1762// START SERVER
1763// =============================================================
1764app.listen(PORT, () => {
1765 console.log(`\n🌸 Wedding Planner server running at http://localhost:${PORT}`);
1766 console.log(` Dashboard → http://localhost:${PORT}/Wedding_Planner.html`);
1767 console.log(` Login → http://localhost:${PORT}/login.html`);
1768 console.log(` RSVP page → http://localhost:${PORT}/rsvp.html\n`);
1769});
1770
1771// Global error handler — convert ApiError and DB trigger errors into structured JSON responses
1772app.use((err, req, res, next) => {
1773 console.error('Unhandled error:', err && err.stack ? err.stack : err);
1774 if (!err) return res.status(500).json({ error: 'Server error.' });
1775 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
1776 if (err.code === 'P0001' || String(err.message || '').toLowerCase().includes('already booked') || String(err.message || '').toLowerCase().includes('conflict')) {
1777 return res.status(409).json({ error: err.message });
1778 }
1779 return res.status(500).json({ error: 'Server error.' });
1780});
Note: See TracBrowser for help on using the repository browser.