Changeset 5b2dd1d
- Timestamp:
- 06/21/26 20:23:47 (3 weeks ago)
- Branches:
- main
- Children:
- 768ae81
- Parents:
- 5025f0f
- git-author:
- Marija Taseska <108740625+marijataseska@…> (06/21/26 20:23:47)
- git-committer:
- GitHub <noreply@…> (06/21/26 20:23:47)
- Files:
-
- 2 edited
-
Wedding_Planner.html (modified) (15 diffs)
-
server.js (modified) (37 diffs)
Legend:
- Unmodified
- Added
- Removed
-
Wedding_Planner.html
r5025f0f r5b2dd1d 339 339 let ATTENDANCE = []; 340 340 let RSVPS = []; 341 let BUDGET_STATUS = null; // { predefined_budget, total_cost, exceeded, over_by, ... } 342 343 // ============================================================= 344 // COUPLE NAMES (local-only, used for invitation emails) 345 // Stored in localStorage — never sent to/stored in the database. 346 // ============================================================= 347 const COUPLE_NAMES_KEY = 'wp_couple_names'; 348 function getCoupleNames(){ 349 try { 350 const raw = localStorage.getItem(COUPLE_NAMES_KEY); 351 if(!raw) return { bride_name:'', groom_name:'' }; 352 const parsed = JSON.parse(raw); 353 return { bride_name: parsed.bride_name || '', groom_name: parsed.groom_name || '' }; 354 } catch(e){ return { bride_name:'', groom_name:'' }; } 355 } 356 function saveCoupleNames(bride_name, groom_name){ 357 localStorage.setItem(COUPLE_NAMES_KEY, JSON.stringify({ bride_name, groom_name })); 358 } 359 360 // Show alert + toast when a booking pushes total spending over the predefined budget 361 function showBudgetAlert(budget) { 362 if (!budget || !budget.exceeded) return; 363 const over = Number(budget.over_by).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 364 const spent = Number(budget.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 365 const predefined = Number(budget.predefined_budget).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 366 const msg = `⚠️ Budget Exceeded!\n\nPredefined budget: $${predefined}\nSpent so far: $${spent}\nOver by: $${over}`; 367 showToast(`Budget exceeded by $${over}`, 'danger'); 368 alert(msg); 369 } 370 371 // ============================================================= 372 // BUDGET CALCULATION (runs entirely from already-loaded data) 373 // Venue : guests × price_per_guest (per confirmed booking) 374 // Band : hours × price_per_hour (booking_cost from server) 375 // Photo : hours × price_per_hour (booking_cost from server) 376 // ============================================================= 377 function calcBudgetSpent() { 378 const guestCount = GUESTS.length; 379 380 const venueCost = VENUE_BOOKINGS.reduce((sum, b) => { 381 const venue = VENUES.find(v => String(v.venue_id) === String(b.venue_id)); 382 const ppg = venue ? Number(venue.price_per_guest || 0) : 0; 383 return sum + ppg * guestCount; 384 }, 0); 385 386 // Calculate hours from time strings "HH:MM" or "HH:MM:SS" 387 const toHours = (start, end) => { 388 const toMin = t => { const p = String(t).split(':').map(Number); return p[0]*60+(p[1]||0); }; 389 return Math.max(0, (toMin(end) - toMin(start)) / 60); 390 }; 391 392 const bandCost = BAND_BOOKINGS.reduce((sum, b) => { 393 const hours = b.hours ? Number(b.hours) : toHours(b.start_time, b.end_time); 394 return sum + hours * Number(b.price_per_hour || 0); 395 }, 0); 396 397 const photoCost = PHOTOGRAPHER_BOOKINGS.reduce((sum, b) => { 398 const hours = b.hours ? Number(b.hours) : toHours(b.start_time, b.end_time); 399 return sum + hours * Number(b.price_per_hour || 0); 400 }, 0); 401 402 const total = venueCost + bandCost + photoCost; 403 const predefined = CURRENT_WEDDING ? Number(CURRENT_WEDDING.budget || 0) : 0; 404 const exceeded = predefined > 0 && total > predefined; 405 406 return { venueCost, bandCost, photoCost, total, predefined, exceeded, over_by: exceeded ? +(total - predefined).toFixed(2) : 0 }; 407 } 408 409 // Check and show alert if budget is exceeded — call after any operation that changes cost/guests 410 function checkBudgetAndAlert() { 411 if (!CURRENT_WEDDING || !CURRENT_WEDDING.budget) return; 412 const s = calcBudgetSpent(); 413 if (s.exceeded) { 414 const over = Number(s.over_by).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 415 const spent = Number(s.total).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 416 const predefined = Number(s.predefined).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 417 showToast(`⚠️ Budget exceeded by $${over}`, 'danger'); 418 alert(`⚠️ Budget Exceeded!\n\nPredefined budget: $${predefined}\nSpent so far: $${spent}\nOver by: $${over}\n\nBreakdown:\n Venue: $${Number(s.venueCost).toLocaleString()}\n Band: $${Number(s.bandCost).toLocaleString()}\n Photographer: $${Number(s.photoCost).toLocaleString()}`); 419 } 420 } 341 421 342 422 // ============================================================= … … 406 486 if (cbR && cbR.ok) CHURCH_BOOKINGS = await cbR.json(); 407 487 if (attR && attR.ok) ATTENDANCE = await attR.json(); 488 // Load budget status (predefined vs spent) 489 const budR = await api('GET', `/api/weddings/${wid}/budget`); 490 if (budR && budR.ok) BUDGET_STATUS = await budR.json(); 408 491 } 409 492 … … 468 551 const rsvpR = await api('GET', `/api/weddings/${wid}/rsvp`); 469 552 if (rsvpR && rsvpR.ok) RSVPS = await rsvpR.json(); 553 // Refresh budget status 554 const budR = await api('GET', `/api/weddings/${wid}/budget`); 555 if (budR && budR.ok) BUDGET_STATUS = await budR.json(); 470 556 renderApp(); 471 557 } … … 806 892 <div class="stat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 2l3 7H21l-5.5 4 2 7L12 16l-5.5 4 2-7L3 9h6z"/></svg></div> 807 893 <div class="stat-label">Budget</div> 808 <div class="stat-value">$ ${w.budget ?(w.budget/1000).toFixed(0)+'k' : '—'}</div>809 <div class="stat-sub"> Totalbudget</div>894 <div class="stat-value">${w.budget ? '$'+(w.budget/1000).toFixed(0)+'k' : '—'}</div> 895 <div class="stat-sub">Predefined budget</div> 810 896 </div> 811 897 <div class="stat-card stat-green"> … … 835 921 <div class="info-row"><span class="info-label">Date</span><span class="info-val">${w.date}</span></div> 836 922 <div class="info-row"><span class="info-label">Budget</span><span class="info-val">${w.budget ? '$'+Number(w.budget).toLocaleString() : 'Not set'}</span></div> 923 ${(() => { 924 const s = calcBudgetSpent(); 925 if (!s.predefined && !s.total) return ''; 926 const pct = s.predefined > 0 ? Math.min((s.total / s.predefined) * 100, 100) : 0; 927 const spentFmt = '$' + Number(s.total).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 928 const barColor = s.exceeded ? 'var(--rose)' : pct > 80 ? '#f59e0b' : '#22c55e'; 929 return `<div class="info-row" style="flex-direction:column;align-items:flex-start;gap:6px;padding-bottom:4px;"> 930 <div style="display:flex;justify-content:space-between;width:100%;"> 931 <span class="info-label">Budget Spent</span> 932 <span class="info-val" style="color:${barColor};font-weight:700;">${spentFmt}${s.exceeded ? ' ⚠️ Over!' : ''}</span> 933 </div> 934 ${s.predefined > 0 ? `<div style="width:100%;background:#e5e7eb;border-radius:999px;height:7px;overflow:hidden;"> 935 <div style="height:100%;width:${pct.toFixed(1)}%;background:${barColor};border-radius:999px;"></div> 936 </div> 937 <div style="font-size:11px;color:var(--text-mid);"> 938 ${pct.toFixed(0)}% of $${Number(s.predefined).toLocaleString()} used 939 ${s.exceeded ? ' — over by $'+Number(s.over_by).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}) : ''} 940 · Venue $${Number(s.venueCost).toLocaleString()} · Band $${Number(s.bandCost).toLocaleString()} · Photo $${Number(s.photoCost).toLocaleString()} 941 </div>` : ''} 942 </div>`; 943 })()} 837 944 <div class="info-row"><span class="info-label">Notes</span><span class="info-val" style="font-size:12px;color:var(--text-mid);">${w.notes || '—'}</span></div> 838 945 <div class="info-row"><span class="info-label">Photographers</span><span class="info-val">${PHOTOGRAPHER_BOOKINGS.length} booked</span></div> … … 988 1095 showModal('Import Guests from CSV', body, async () => { 989 1096 const sendEmails = document.getElementById('csvSendEmails').checked; 990 const res = await api('POST', `/api/weddings/${CURRENT_WEDDING.wedding_id}/guests/bulk`, { guests: rows, sendEmails }); 1097 const couple = getCoupleNames(); 1098 const res = await api('POST', `/api/weddings/${CURRENT_WEDDING.wedding_id}/guests/bulk`, { guests: rows, sendEmails, bride_name: couple.bride_name, groom_name: couple.groom_name }); 991 1099 if(res && res.ok){ const d = await res.json(); closeModal(); showToast(`${d.inserted} guests imported.`,'success'); await reload(); } 992 1100 else { const d = await res.json().catch(()=>null); showToast((d&&d.error) || 'Error importing guests.','danger'); } … … 1095 1203 const first = document.getElementById('gFirst').value.trim(); 1096 1204 if(!first){ showToast('First name is required.','danger'); return; } 1205 const couple = getCoupleNames(); 1097 1206 const res = await api('POST',`/api/weddings/${CURRENT_WEDDING.wedding_id}/guests`,{ 1098 1207 first_name: first, 1099 1208 last_name: document.getElementById('gLast').value.trim(), 1100 1209 email: document.getElementById('gEmail').value.trim(), 1101 role: document.getElementById('gRole').value 1210 role: document.getElementById('gRole').value, 1211 bride_name: couple.bride_name, 1212 groom_name: couple.groom_name 1102 1213 }); 1103 1214 if(res && res.ok){ … … 1106 1217 showToast(d.emailSent ? 'Guest added & invitation email sent! 📧' : 'Guest added (no email provided).','success'); 1107 1218 await reload(); 1219 checkBudgetAndAlert(); 1108 1220 } else showToast('Could not add guest.','danger'); 1109 1221 } … … 1279 1391 }); 1280 1392 if(res && res.ok){ closeModal(); showToast('Guest assigned to table!','success'); await reload(); } 1281 else { const d=await res.json(); showToast(d.error||'Error assigning guest.','danger'); } 1393 else { 1394 const d=await res.json(); 1395 const msg = d.error||'Error assigning guest.'; 1396 showToast(msg,'danger'); 1397 alert(msg); 1398 } 1282 1399 } 1283 1400 ); … … 1310 1427 }); 1311 1428 if(res && res.ok){ closeModal(); showToast('Seating updated!','success'); await reload(); } 1312 else showToast('Error updating seating.','danger'); 1429 else { 1430 const d=await res.json().catch(()=>({})); 1431 const msg = d.error||'Error updating seating.'; 1432 showToast(msg,'danger'); 1433 alert(msg); 1434 } 1313 1435 } 1314 1436 ); … … 1384 1506 status: 'confirmed' 1385 1507 }); 1386 if(res && res.ok){ closeModal(); showToast('Venue booked!','success'); await reload(); } 1508 if(res && res.ok){ 1509 closeModal(); 1510 showToast('Venue booked!','success'); 1511 await reload(); 1512 checkBudgetAndAlert(); 1513 } 1387 1514 else { const d=await res.json(); showToast(d.error||'Error booking venue.','danger'); } 1388 1515 } … … 1452 1579 status: 'confirmed' 1453 1580 }); 1454 if(res && res.ok){ closeModal(); showToast('Band booked!','success'); await reload(); } 1581 if(res && res.ok){ 1582 closeModal(); 1583 showToast('Band booked!','success'); 1584 await reload(); 1585 checkBudgetAndAlert(); 1586 } 1455 1587 else { const d=await res.json(); showToast(d.error||'Error booking band.','danger'); } 1456 1588 } … … 1520 1652 status: 'confirmed' 1521 1653 }); 1522 if(res && res.ok){ closeModal(); showToast('Photographer booked!','success'); await reload(); } 1654 if(res && res.ok){ 1655 closeModal(); 1656 showToast('Photographer booked!','success'); 1657 await reload(); 1658 checkBudgetAndAlert(); 1659 } 1523 1660 else { const d=await res.json(); showToast(d.error||'Error booking photographer.','danger'); } 1524 1661 } … … 1671 1808 function renderSettingsPage(container){ 1672 1809 const u = CURRENT_USER; 1810 const couple = getCoupleNames(); 1673 1811 const wrap = document.createElement('div'); 1674 1812 wrap.innerHTML = ` … … 1702 1840 <button class="btn btn-ghost" onclick="renderApp()">Reset</button> 1703 1841 </div> 1842 </div> 1843 <div class="card" style="margin-top:18px;"> 1844 <div class="card-header"><h3>💍 Invitation Names</h3></div> 1845 <div class="gold-rule"></div> 1846 <p class="muted" style="margin-bottom:16px;">These names are used on guest invitations </p> 1847 <div class="form-grid"> 1848 <div class="form-group"><label class="form-label">Bride's Name</label><input id="sBrideName" placeholder="e.g. Maria" value="${escapeHtml(couple.bride_name)}"/></div> 1849 <div class="form-group"><label class="form-label">Groom's Name</label><input id="sGroomName" placeholder="e.g. John" value="${escapeHtml(couple.groom_name)}"/></div> 1850 </div> 1851 <div style="display:flex;gap:10px;margin-top:20px;"> 1852 <button class="btn btn-primary" id="btnSaveCoupleNames">Save Names</button> 1853 </div> 1704 1854 </div>`; 1705 1855 container.appendChild(wrap); 1856 1857 wrap.querySelector('#btnSaveCoupleNames').addEventListener('click', () => { 1858 const bride_name = document.getElementById('sBrideName').value.trim(); 1859 const groom_name = document.getElementById('sGroomName').value.trim(); 1860 saveCoupleNames(bride_name, groom_name); 1861 showToast('Invitation names saved on this device!','success'); 1862 }); 1706 1863 1707 1864 wrap.querySelector('#btnSaveProfile').addEventListener('click', async () => { -
server.js
r5025f0f r5b2dd1d 65 65 // POST /api/weddings/:wid/guests/bulk — add multiple guests in a transaction 66 66 app.post('/api/weddings/:wid/guests/bulk', requireAuth, async (req, res) => { 67 const { guests, sendEmails } = req.body;67 const { guests, sendEmails, bride_name, groom_name } = req.body; 68 68 if (!Array.isArray(guests) || guests.length === 0) return res.status(400).json({ error: 'guests array is required.' }); 69 69 … … 81 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 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 } 83 114 84 115 await client.query('BEGIN'); … … 89 120 const email = (g.email || null); 90 121 const role = (g.role || null); 91 if (!first_name || !last_name) {92 await client.query('ROLLBACK');93 return res.status(400).json({ error: 'Each guest must have first_name and last_name.' });94 }95 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]); 96 123 const guest = ins.rows[0]; … … 115 142 await client.query('COMMIT'); 116 143 117 // Optionally send emails after commit 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. 118 147 if (sendEmails) { 148 const bride = bride_name || 'the Bride'; 149 const groom = groom_name || 'the Groom'; 119 150 for (const guest of inserted) { 120 151 if (!guest.email) continue; … … 139 170 from: `"Wedding Planner" <${process.env.EMAIL_USER}>`, 140 171 to: guest.email, 141 subject: `You're Invited! 💍 ${ req.session.user.first_name} & ${req.session.user.last_name}'s Wedding`,172 subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`, 142 173 html: ` 143 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;"> … … 202 233 } 203 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 204 251 const BookingService = { 205 252 // Normalize date value (DATE from PG may be string or Date) … … 225 272 // Gather all bookings/events for a given wedding and date 226 273 async getBookingsForWeddingDate(wedding_id, date) { 274 const dateOnly = BookingService._fmtDate(date); 227 275 const q = ` 228 276 SELECT 'venue' AS type, booking_id AS id, start_time, end_time 229 FROM project.venue_booking WHERE wedding_id=$1 AND "date" =$2277 FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date 230 278 UNION ALL 231 279 SELECT 'church' AS type, booking_id AS id, start_time, end_time 232 FROM project.church_booking WHERE wedding_id=$1 AND "date" =$2280 FROM project.church_booking WHERE wedding_id=$1 AND "date" = $2::date 233 281 UNION ALL 234 282 SELECT 'registrar' AS type, booking_id AS id, start_time, end_time 235 FROM project.registrar_booking WHERE wedding_id=$1 AND "date" =$2283 FROM project.registrar_booking WHERE wedding_id=$1 AND "date" = $2::date 236 284 UNION ALL 237 285 SELECT 'photographer' AS type, booking_id AS id, start_time, end_time 238 FROM project.photographer_booking WHERE wedding_id=$1 AND "date" =$2286 FROM project.photographer_booking WHERE wedding_id=$1 AND "date" = $2::date 239 287 UNION ALL 240 288 SELECT 'band' AS type, booking_id AS id, start_time, end_time 241 FROM project.band_booking WHERE wedding_id=$1 AND "date" =$2289 FROM project.band_booking WHERE wedding_id=$1 AND "date" = $2::date 242 290 UNION ALL 243 291 SELECT 'event' AS type, event_id AS id, start_time, end_time 244 FROM project.event WHERE wedding_id=$1 AND "date" =$2292 FROM project.event WHERE wedding_id=$1 AND "date" = $2::date 245 293 `; 246 const res = await pool.query(q, [wedding_id, date ]);294 const res = await pool.query(q, [wedding_id, dateOnly]); 247 295 return res.rows; 248 296 }, … … 251 299 async validateDateMatchesWedding(wedding_id, date) { 252 300 // Use SQL date cast/comparison to avoid client-side format issues 253 const r = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1 AND "date" = $2::date', [wedding_id, date]); 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]); 254 303 if (r.rows.length === 0) { 255 304 // Determine whether wedding not found or date mismatch … … 263 312 // exclude optional { type, id } to allow updating existing record 264 313 async validateNoOverlap(wedding_id, date, start_time, end_time, exclude) { 265 const bookings = await BookingService.getBookingsForWeddingDate(wedding_id, date); 314 const dateOnly = BookingService._fmtDate(date); 315 const bookings = await BookingService.getBookingsForWeddingDate(wedding_id, dateOnly); 266 316 for (const b of bookings) { 267 317 if (exclude && String(exclude.type) === String(b.type) && String(exclude.id) === String(b.id)) continue; 268 318 if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) { 269 throw new ApiError(409, 'T ime slot conflict detected: this booking overlaps with another scheduled event.');319 throw new ApiError(409, 'This has been already booked, please change date or time to rebook'); 270 320 } 271 321 } … … 281 331 282 332 // find venue booking(s) for the wedding on the date 283 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', [wedding_id, 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]); 284 335 if (vb.rows.length === 0) { 285 336 // no venue booked on that date -> not allowed … … 289 340 const match = vb.rows.some(x => String(x.location).trim().toLowerCase() === String(regLoc).trim().toLowerCase()); 290 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 366 const 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 }; 291 449 } 292 450 }; … … 469 627 const result = await pool.query( 470 628 'INSERT INTO project.wedding("date", budget, notes, user_id) VALUES ($1,$2,$3,$4) RETURNING *', 471 [date, budget || null, notes || null, req.session.user.user_id]629 [date, budget ? Number(budget) : 0, notes || null, req.session.user.user_id] 472 630 ); 473 631 res.status(201).json(result.rows[0]); … … 485 643 `UPDATE project.wedding SET "date"=$1, budget=$2, notes=$3 486 644 WHERE wedding_id=$4 AND user_id=$5 RETURNING *`, 487 [date, budget ||null, notes || null, req.params.id, req.session.user.user_id]645 [date, budget !== undefined ? Number(budget) : null, notes || null, req.params.id, req.session.user.user_id] 488 646 ); 489 647 if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' }); 490 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 656 app.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); 491 665 } catch (err) { 492 666 console.error(err.message); … … 546 720 console.error(err.message); 547 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 } 548 726 res.status(500).json({ error: 'Server error.' }); 549 727 } … … 573 751 console.error(err.message); 574 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 } 575 756 res.status(500).json({ error: 'Server error.' }); 576 757 } … … 608 789 // POST /api/weddings/:wid/guests — add guest + send email invite 609 790 app.post('/api/weddings/:wid/guests', requireAuth, async (req, res) => { 610 const { first_name, last_name, email, role } = req.body;791 const { first_name, last_name, email, role, bride_name, groom_name } = req.body; 611 792 if (!first_name || !last_name) 612 793 return res.status(400).json({ error: 'First name and last name are required.' }); 613 794 614 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 615 803 // Insert guest 616 804 const result = await pool.query( … … 620 808 const guest = result.rows[0]; 621 809 622 // Fetch wedding info for the email 810 // Fetch wedding info for the email (use account owner name as the couple) 623 811 const weddingResult = await pool.query( 624 812 `SELECT w."date", u.first_name AS owner_first, u.last_name AS owner_last … … 628 816 [req.params.wid] 629 817 ); 630 const wedding = weddingResult.rows[0] ;818 const wedding = weddingResult.rows[0] || {}; 631 819 632 820 // Fetch all events for this wedding so guest can RSVP … … 678 866 }).join(''); 679 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 680 874 const mailOptions = { 681 875 from: `"Wedding Planner" <${process.env.EMAIL_USER}>`, 682 876 to: email, 683 subject: `You're Invited! 💍 ${ wedding.owner_first} & ${wedding.owner_last}'s Wedding`,877 subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`, 684 878 html: ` 685 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;"> … … 687 881 <div style="font-size:36px;margin-bottom:8px;">💍</div> 688 882 <h1 style="color:#fff;font-size:26px;margin:0;">You're Invited!</h1> 689 <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding of ${ wedding.owner_first} & ${wedding.owner_last}</p>883 <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding of ${bride} & ${groom}</p> 690 884 </div> 691 885 <div style="padding:32px;"> … … 693 887 <p style="font-size:15px;color:#6b4f4f;line-height:1.6;"> 694 888 We are delighted to invite you to celebrate the wedding of 695 <strong>${ wedding.owner_first} & ${wedding.owner_last}</strong>889 <strong>${bride} & ${groom}</strong> 696 890 on <strong>${wedding.date}</strong>. 697 891 </p> … … 809 1003 ); 810 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 811 1049 res.json({ 812 1050 rsvp: result.rows[0], 813 guest: guestInfo.rows[0] || null1051 guest: info 814 1052 }); 815 1053 } catch (err) { … … 897 1135 const rsvpStatus = rsvpResult.rows[0]?.status || 'pending'; 898 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 899 1147 const result = await pool.query( 900 1148 `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id) … … 905 1153 [rsvpStatus, table_number || null, role, guest_id, event_id] 906 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 907 1164 res.status(201).json(result.rows[0]); 908 1165 } catch (err) { … … 933 1190 const rsvpStatus = rsvpResult.rows[0]?.status || 'pending'; 934 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 935 1202 const result = await pool.query( 936 1203 `UPDATE project.attendance SET status=$1, table_number=$2, role=$3 … … 939 1206 ); 940 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 941 1216 res.json(result.rows[0]); 942 1217 } catch (err) { … … 1001 1276 return res.status(400).json({ error: 'date, start_time, end_time and venue_id are required.' }); 1002 1277 1003 // Check availability: no overlapping booking for same venue on same date 1004 try { 1005 // Business validations 1006 await BookingService.validateDateMatchesWedding(req.params.wid, date); 1007 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); 1008 1009 const conflict = await pool.query( 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( 1010 1306 `SELECT booking_id FROM project.venue_booking 1011 WHERE venue_id=$1 AND "date"=$2 1012 AND NOT (end_time <= $3 OR start_time >= $4)`, 1013 [venue_id, date, start_time, end_time] 1014 ); 1015 if (conflict.rows.length > 0) 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'); 1016 1315 return res.status(409).json({ error: 'Venue is already booked during this time slot.' }); 1017 1018 const result = await pool.query( 1316 } 1317 1318 // insert booking 1319 const ins = await client.query( 1019 1320 `INSERT INTO project.venue_booking("date",start_time,end_time,status,price,venue_id,wedding_id) 1020 1321 VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, 1021 [date, start_time, end_time, status || 'confirmed', price || 0, venue_id, req.params.wid] 1022 ); 1023 res.status(201).json(result.rows[0]); 1024 } catch (err) { 1025 console.error(err.message); 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); 1026 1332 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); 1027 res.status(500).json({ error: 'Server error.' }); 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(); 1028 1337 } 1029 1338 }); … … 1032 1341 app.delete('/api/venue-bookings/:id', requireAuth, async (req, res) => { 1033 1342 try { 1034 await pool.query('DELETE FROM project.venue_booking WHERE booking_id=$1', [req.params.id]); 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); 1035 1348 res.json({ message: 'Venue booking cancelled.' }); 1036 1349 } catch (err) { … … 1059 1372 try { 1060 1373 const result = await pool.query( 1061 `SELECT bb.*, b.band_name, b.genre 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 1062 1377 FROM project.band_booking bb 1063 1378 JOIN project.band b ON bb.band_id = b.band_id … … 1077 1392 if (!date || !start_time || !end_time || !band_id) 1078 1393 return res.status(400).json({ error: 'date, start_time, end_time and band_id are required.' }); 1079 try { 1080 // Business validations 1081 await BookingService.validateDateMatchesWedding(req.params.wid, date); 1082 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); 1083 const result = await pool.query( 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( 1084 1425 `INSERT INTO project.band_booking("date",start_time,end_time,status,band_id,wedding_id) 1085 1426 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, 1086 [date, start_time, end_time, status || 'confirmed', band_id, req.params.wid] 1087 ); 1088 res.status(201).json(result.rows[0]); 1089 } catch (err) { 1090 console.error(err.message); 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); 1091 1437 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); 1092 res.status(500).json({ error: 'Server error.' }); 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(); 1093 1442 } 1094 1443 }); … … 1097 1446 app.delete('/api/band-bookings/:id', requireAuth, async (req, res) => { 1098 1447 try { 1099 await pool.query('DELETE FROM project.band_booking WHERE booking_id=$1', [req.params.id]); 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); 1100 1453 res.json({ message: 'Band booking removed.' }); 1101 1454 } catch (err) { … … 1124 1477 try { 1125 1478 const result = await pool.query( 1126 `SELECT pb.*, p.name AS photographer_name, p.email AS photographer_email 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 1127 1482 FROM project.photographer_booking pb 1128 1483 JOIN project.photographer p ON pb.photographer_id = p.photographer_id … … 1142 1497 if (!date || !start_time || !end_time || !photographer_id) 1143 1498 return res.status(400).json({ error: 'date, start_time, end_time and photographer_id are required.' }); 1144 try { 1145 // Business validations 1146 await BookingService.validateDateMatchesWedding(req.params.wid, date); 1147 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); 1148 const result = await pool.query( 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( 1149 1528 `INSERT INTO project.photographer_booking("date",start_time,end_time,status,photographer_id,wedding_id) 1150 1529 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, 1151 [date, start_time, end_time, status || 'confirmed', photographer_id, req.params.wid] 1152 ); 1153 res.status(201).json(result.rows[0]); 1154 } catch (err) { 1155 console.error(err.message); 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); 1156 1540 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); 1157 res.status(500).json({ error: 'Server error.' }); 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(); 1158 1545 } 1159 1546 }); … … 1162 1549 app.delete('/api/photographer-bookings/:id', requireAuth, async (req, res) => { 1163 1550 try { 1164 await pool.query('DELETE FROM project.photographer_booking WHERE booking_id=$1', [req.params.id]); 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); 1165 1556 res.json({ message: 'Photographer booking removed.' }); 1166 1557 } catch (err) { … … 1214 1605 if (!date || !start_time || !end_time || !church_id) 1215 1606 return res.status(400).json({ error: 'date, start_time, end_time and church_id are required.' }); 1216 try { 1217 // Business validations 1218 await BookingService.validateDateMatchesWedding(req.params.wid, date); 1219 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); 1220 const result = await pool.query( 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( 1221 1636 `INSERT INTO project.church_booking("date",start_time,end_time,status,church_id,wedding_id) 1222 1637 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, 1223 [date, start_time, end_time, status || 'confirmed', church_id, req.params.wid] 1224 ); 1225 res.status(201).json(result.rows[0]); 1226 } catch (err) { 1227 console.error(err.message); 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); 1228 1646 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); 1229 res.status(500).json({ error: 'Server error.' }); 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(); 1230 1651 } 1231 1652 }); … … 1279 1700 if (!date || !start_time || !end_time || !registrar_id) 1280 1701 return res.status(400).json({ error: 'date, start_time, end_time and registrar_id are required.' }); 1281 try { 1282 // Business validations 1283 await BookingService.validateDateMatchesWedding(req.params.wid, date); 1284 await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null); 1285 await BookingService.validateRegistrarLocation(req.params.wid, date, registrar_id); 1286 1287 const result = await pool.query( 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( 1288 1732 `INSERT INTO project.registrar_booking("date",start_time,end_time,status,registrar_id,wedding_id) 1289 1733 VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, 1290 [date, start_time, end_time, status || 'confirmed', registrar_id, req.params.wid] 1291 ); 1292 res.status(201).json(result.rows[0]); 1293 } catch (err) { 1294 console.error(err.message); 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); 1295 1742 if (err instanceof ApiError) return res.status(err.status).json({ error: err.message }); 1296 res.status(500).json({ error: 'Server error.' }); 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(); 1297 1747 } 1298 1748 }); … … 1318 1768 console.log(` RSVP page → http://localhost:${PORT}/rsvp.html\n`); 1319 1769 }); 1770 1771 // Global error handler — convert ApiError and DB trigger errors into structured JSON responses 1772 app.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 TracChangeset
for help on using the changeset viewer.
