Changeset 5b2dd1d


Ignore:
Timestamp:
06/21/26 20:23:47 (3 weeks ago)
Author:
GitHub <noreply@…>
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)
Message:

Add files via upload

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • Wedding_Planner.html

    r5025f0f r5b2dd1d  
    339339let ATTENDANCE     = [];
    340340let RSVPS = [];
     341let 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// =============================================================
     347const COUPLE_NAMES_KEY = 'wp_couple_names';
     348function 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}
     356function 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
     361function 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// =============================================================
     377function 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
     410function 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}
    341421
    342422// =============================================================
     
    406486        if (cbR && cbR.ok) CHURCH_BOOKINGS = await cbR.json();
    407487        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();
    408491    }
    409492
     
    468551    const rsvpR = await api('GET', `/api/weddings/${wid}/rsvp`);
    469552    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();
    470556    renderApp();
    471557}
     
    806892            <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>
    807893            <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">Total budget</div>
     894            <div class="stat-value">${w.budget ? '$'+(w.budget/1000).toFixed(0)+'k' : '—'}</div>
     895            <div class="stat-sub">Predefined budget</div>
    810896        </div>
    811897        <div class="stat-card stat-green">
     
    835921                <div class="info-row"><span class="info-label">Date</span><span class="info-val">${w.date}</span></div>
    836922                <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                            &nbsp;·&nbsp; Venue $${Number(s.venueCost).toLocaleString()} · Band $${Number(s.bandCost).toLocaleString()} · Photo $${Number(s.photoCost).toLocaleString()}
     941                        </div>` : ''}
     942                    </div>`;
     943                })()}
    837944                <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>
    838945                <div class="info-row"><span class="info-label">Photographers</span><span class="info-val">${PHOTOGRAPHER_BOOKINGS.length} booked</span></div>
     
    9881095            showModal('Import Guests from CSV', body, async () => {
    9891096                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 });
    9911099                if(res && res.ok){ const d = await res.json(); closeModal(); showToast(`${d.inserted} guests imported.`,'success'); await reload(); }
    9921100                else { const d = await res.json().catch(()=>null); showToast((d&&d.error) || 'Error importing guests.','danger'); }
     
    10951203            const first = document.getElementById('gFirst').value.trim();
    10961204            if(!first){ showToast('First name is required.','danger'); return; }
     1205            const couple = getCoupleNames();
    10971206            const res = await api('POST',`/api/weddings/${CURRENT_WEDDING.wedding_id}/guests`,{
    10981207                first_name: first,
    10991208                last_name: document.getElementById('gLast').value.trim(),
    11001209                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
    11021213            });
    11031214            if(res && res.ok){
     
    11061217                showToast(d.emailSent ? 'Guest added & invitation email sent! 📧' : 'Guest added (no email provided).','success');
    11071218                await reload();
     1219                checkBudgetAndAlert();
    11081220            } else showToast('Could not add guest.','danger');
    11091221        }
     
    12791391            });
    12801392            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            }
    12821399        }
    12831400    );
     
    13101427            });
    13111428            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            }
    13131435        }
    13141436    );
     
    13841506                status: 'confirmed'
    13851507            });
    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            }
    13871514            else { const d=await res.json(); showToast(d.error||'Error booking venue.','danger'); }
    13881515        }
     
    14521579                status: 'confirmed'
    14531580            });
    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            }
    14551587            else { const d=await res.json(); showToast(d.error||'Error booking band.','danger'); }
    14561588        }
     
    15201652                status: 'confirmed'
    15211653            });
    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            }
    15231660            else { const d=await res.json(); showToast(d.error||'Error booking photographer.','danger'); }
    15241661        }
     
    16711808function renderSettingsPage(container){
    16721809    const u = CURRENT_USER;
     1810    const couple = getCoupleNames();
    16731811    const wrap = document.createElement('div');
    16741812    wrap.innerHTML = `
     
    17021840            <button class="btn btn-ghost" onclick="renderApp()">Reset</button>
    17031841        </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>
    17041854    </div>`;
    17051855    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    });
    17061863
    17071864    wrap.querySelector('#btnSaveProfile').addEventListener('click', async () => {
  • server.js

    r5025f0f r5b2dd1d  
    6565// POST /api/weddings/:wid/guests/bulk — add multiple guests in a transaction
    6666app.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;
    6868    if (!Array.isArray(guests) || guests.length === 0) return res.status(400).json({ error: 'guests array is required.' });
    6969
     
    8181        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]);
    8282        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        }
    83114
    84115        await client.query('BEGIN');
     
    89120            const email = (g.email || null);
    90121            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             }
    95122            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]);
    96123            const guest = ins.rows[0];
     
    115142        await client.query('COMMIT');
    116143
    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.
    118147        if (sendEmails) {
     148            const bride = bride_name || 'the Bride';
     149            const groom = groom_name || 'the Groom';
    119150            for (const guest of inserted) {
    120151                if (!guest.email) continue;
     
    139170                        from: `"Wedding Planner" <${process.env.EMAIL_USER}>`,
    140171                        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`,
    142173                        html: `
    143174                            <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;">
     
    202233}
    203234
     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
    204251const BookingService = {
    205252    // Normalize date value (DATE from PG may be string or Date)
     
    225272    // Gather all bookings/events for a given wedding and date
    226273    async getBookingsForWeddingDate(wedding_id, date) {
     274        const dateOnly = BookingService._fmtDate(date);
    227275        const q = `
    228276            SELECT 'venue' AS type, booking_id AS id, start_time, end_time
    229             FROM project.venue_booking WHERE wedding_id=$1 AND "date"=$2
     277            FROM project.venue_booking WHERE wedding_id=$1 AND "date" = $2::date
    230278            UNION ALL
    231279            SELECT 'church' AS type, booking_id AS id, start_time, end_time
    232             FROM project.church_booking WHERE wedding_id=$1 AND "date"=$2
     280            FROM project.church_booking WHERE wedding_id=$1 AND "date" = $2::date
    233281            UNION ALL
    234282            SELECT 'registrar' AS type, booking_id AS id, start_time, end_time
    235             FROM project.registrar_booking WHERE wedding_id=$1 AND "date"=$2
     283            FROM project.registrar_booking WHERE wedding_id=$1 AND "date" = $2::date
    236284            UNION ALL
    237285            SELECT 'photographer' AS type, booking_id AS id, start_time, end_time
    238             FROM project.photographer_booking WHERE wedding_id=$1 AND "date"=$2
     286            FROM project.photographer_booking WHERE wedding_id=$1 AND "date" = $2::date
    239287            UNION ALL
    240288            SELECT 'band' AS type, booking_id AS id, start_time, end_time
    241             FROM project.band_booking WHERE wedding_id=$1 AND "date"=$2
     289            FROM project.band_booking WHERE wedding_id=$1 AND "date" = $2::date
    242290            UNION ALL
    243291            SELECT 'event' AS type, event_id AS id, start_time, end_time
    244             FROM project.event WHERE wedding_id=$1 AND "date"=$2
     292            FROM project.event WHERE wedding_id=$1 AND "date" = $2::date
    245293        `;
    246         const res = await pool.query(q, [wedding_id, date]);
     294        const res = await pool.query(q, [wedding_id, dateOnly]);
    247295        return res.rows;
    248296    },
     
    251299    async validateDateMatchesWedding(wedding_id, date) {
    252300        // 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]);
    254303        if (r.rows.length === 0) {
    255304            // Determine whether wedding not found or date mismatch
     
    263312    // exclude optional { type, id } to allow updating existing record
    264313    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);
    266316        for (const b of bookings) {
    267317            if (exclude && String(exclude.type) === String(b.type) && String(exclude.id) === String(b.id)) continue;
    268318            if (BookingService.isOverlapping(start_time, end_time, b.start_time, b.end_time)) {
    269                 throw new ApiError(409, 'Time 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');
    270320            }
    271321        }
     
    281331
    282332        // 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]);
    284335        if (vb.rows.length === 0) {
    285336            // no venue booked on that date -> not allowed
     
    289340        const match = vb.rows.some(x => String(x.location).trim().toLowerCase() === String(regLoc).trim().toLowerCase());
    290341        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        };
    291449    }
    292450};
     
    469627        const result = await pool.query(
    470628            '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]
    472630        );
    473631        res.status(201).json(result.rows[0]);
     
    485643            `UPDATE project.wedding SET "date"=$1, budget=$2, notes=$3
    486644             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]
    488646        );
    489647        if (result.rows.length === 0) return res.status(404).json({ error: 'Wedding not found.' });
    490648        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);
    491665    } catch (err) {
    492666        console.error(err.message);
     
    546720        console.error(err.message);
    547721        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        }
    548726        res.status(500).json({ error: 'Server error.' });
    549727    }
     
    573751        console.error(err.message);
    574752        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        }
    575756        res.status(500).json({ error: 'Server error.' });
    576757    }
     
    608789// POST /api/weddings/:wid/guests — add guest + send email invite
    609790app.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;
    611792    if (!first_name || !last_name)
    612793        return res.status(400).json({ error: 'First name and last name are required.' });
    613794
    614795    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
    615803        // Insert guest
    616804        const result = await pool.query(
     
    620808        const guest = result.rows[0];
    621809
    622         // Fetch wedding info for the email
     810        // Fetch wedding info for the email (use account owner name as the couple)
    623811        const weddingResult = await pool.query(
    624812            `SELECT w."date", u.first_name AS owner_first, u.last_name AS owner_last
     
    628816            [req.params.wid]
    629817        );
    630         const wedding = weddingResult.rows[0];
     818        const wedding = weddingResult.rows[0] || {};
    631819
    632820        // Fetch all events for this wedding so guest can RSVP
     
    678866            }).join('');
    679867
     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
    680874            const mailOptions = {
    681875                from: `"Wedding Planner" <${process.env.EMAIL_USER}>`,
    682876                to: email,
    683                 subject: `You're Invited! 💍 ${wedding.owner_first} & ${wedding.owner_last}'s Wedding`,
     877                subject: `You're Invited! 💍 ${bride} & ${groom}'s Wedding`,
    684878                html: `
    685879                    <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;">
     
    687881                        <div style="font-size:36px;margin-bottom:8px;">💍</div>
    688882                        <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} &amp; ${wedding.owner_last}</p>
     883                        <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding of ${bride} &amp; ${groom}</p>
    690884                      </div>
    691885                      <div style="padding:32px;">
     
    693887                        <p style="font-size:15px;color:#6b4f4f;line-height:1.6;">
    694888                          We are delighted to invite you to celebrate the wedding of
    695                           <strong>${wedding.owner_first} &amp; ${wedding.owner_last}</strong>
     889                          <strong>${bride} &amp; ${groom}</strong>
    696890                          on <strong>${wedding.date}</strong>.
    697891                        </p>
     
    8091003        );
    8101004
     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
    8111049        res.json({
    8121050            rsvp: result.rows[0],
    813             guest: guestInfo.rows[0] || null
     1051            guest: info
    8141052        });
    8151053    } catch (err) {
     
    8971135        const rsvpStatus = rsvpResult.rows[0]?.status || 'pending';
    8981136
     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
    8991147        const result = await pool.query(
    9001148            `INSERT INTO project.attendance(status, table_number, role, guest_id, event_id)
     
    9051153            [rsvpStatus, table_number || null, role, guest_id, event_id]
    9061154        );
     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
    9071164        res.status(201).json(result.rows[0]);
    9081165    } catch (err) {
     
    9331190        const rsvpStatus = rsvpResult.rows[0]?.status || 'pending';
    9341191
     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
    9351202        const result = await pool.query(
    9361203            `UPDATE project.attendance SET status=$1, table_number=$2, role=$3
     
    9391206        );
    9401207        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
    9411216        res.json(result.rows[0]);
    9421217    } catch (err) {
     
    10011276        return res.status(400).json({ error: 'date, start_time, end_time and venue_id are required.' });
    10021277
    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(
    10101306            `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');
    10161315            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(
    10191320            `INSERT INTO project.venue_booking("date",start_time,end_time,status,price,venue_id,wedding_id)
    10201321             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);
    10261332        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();
    10281337    }
    10291338});
     
    10321341app.delete('/api/venue-bookings/:id', requireAuth, async (req, res) => {
    10331342    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);
    10351348        res.json({ message: 'Venue booking cancelled.' });
    10361349    } catch (err) {
     
    10591372    try {
    10601373        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
    10621377             FROM project.band_booking bb
    10631378             JOIN project.band b ON bb.band_id = b.band_id
     
    10771392    if (!date || !start_time || !end_time || !band_id)
    10781393        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(
    10841425            `INSERT INTO project.band_booking("date",start_time,end_time,status,band_id,wedding_id)
    10851426             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);
    10911437        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();
    10931442    }
    10941443});
     
    10971446app.delete('/api/band-bookings/:id', requireAuth, async (req, res) => {
    10981447    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);
    11001453        res.json({ message: 'Band booking removed.' });
    11011454    } catch (err) {
     
    11241477    try {
    11251478        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
    11271482             FROM project.photographer_booking pb
    11281483             JOIN project.photographer p ON pb.photographer_id = p.photographer_id
     
    11421497    if (!date || !start_time || !end_time || !photographer_id)
    11431498        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(
    11491528            `INSERT INTO project.photographer_booking("date",start_time,end_time,status,photographer_id,wedding_id)
    11501529             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);
    11561540        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();
    11581545    }
    11591546});
     
    11621549app.delete('/api/photographer-bookings/:id', requireAuth, async (req, res) => {
    11631550    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);
    11651556        res.json({ message: 'Photographer booking removed.' });
    11661557    } catch (err) {
     
    12141605    if (!date || !start_time || !end_time || !church_id)
    12151606        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(
    12211636            `INSERT INTO project.church_booking("date",start_time,end_time,status,church_id,wedding_id)
    12221637             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);
    12281646        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();
    12301651    }
    12311652});
     
    12791700    if (!date || !start_time || !end_time || !registrar_id)
    12801701        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(
    12881732            `INSERT INTO project.registrar_booking("date",start_time,end_time,status,registrar_id,wedding_id)
    12891733             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);
    12951742        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();
    12971747    }
    12981748});
     
    13181768    console.log(`   RSVP page  → http://localhost:${PORT}/rsvp.html\n`);
    13191769});
     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 TracChangeset for help on using the changeset viewer.