Changeset b7d8a4d


Ignore:
Timestamp:
06/07/26 21:34:27 (4 weeks ago)
Author:
GitHub <noreply@…>
Branches:
main
Children:
679113b
Parents:
3679996
git-author:
Marija Taseska <108740625+marijataseska@…> (06/07/26 21:34:27)
git-committer:
GitHub <noreply@…> (06/07/26 21:34:27)
Message:

Add files via upload

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • Wedding_Planner.html

    r3679996 rb7d8a4d  
    324324let CURRENT_USER   = null;
    325325let CURRENT_WEDDING = null;
     326let WEDDINGS = [];
    326327let EVENTS         = [];
    327328let GUESTS         = [];
     
    337338let REGISTRAR_BOOKINGS = [];
    338339let ATTENDANCE     = [];
     340let RSVPS = [];
    339341
    340342// =============================================================
     
    370372    if (wRes && wRes.ok) {
    371373        const weddings = await wRes.json();
     374        WEDDINGS = weddings;
    372375        CURRENT_WEDDING = weddings[0] || null;
    373376    }
     
    405408    }
    406409
     410    // Load RSVPs for this wedding (admin view)
     411    if (wid) {
     412        const rsvpR = await api('GET', `/api/weddings/${wid}/rsvp`);
     413        if (rsvpR && rsvpR.ok) RSVPS = await rsvpR.json();
     414    }
     415
    407416    const [venR, banR, phR, chR, regR] = await Promise.all([
    408417        api('GET', '/api/venues'),
     
    424433async function reload() {
    425434    if (!CURRENT_WEDDING) return;
     435    // Refresh list of weddings first (in case user created/removed/switches)
     436    try {
     437        const wListR = await api('GET', '/api/weddings');
     438        if (wListR && wListR.ok) {
     439            WEDDINGS = await wListR.json();
     440            // keep CURRENT_WEDDING reference in sync
     441            if (CURRENT_WEDDING) {
     442                const found = WEDDINGS.find(w => String(w.wedding_id) === String(CURRENT_WEDDING.wedding_id));
     443                if (found) CURRENT_WEDDING = found;
     444            }
     445        }
     446    } catch (e) { /* ignore */ }
     447
    426448    const wid = CURRENT_WEDDING.wedding_id;
    427449    const [evR, guR, vbR, pbR, bbR, rbR, cbR, attR] = await Promise.all([
     
    443465    if (cbR && cbR.ok) CHURCH_BOOKINGS = await cbR.json();
    444466    if (attR && attR.ok) ATTENDANCE = await attR.json();
     467    // Refresh RSVP list as well
     468    const rsvpR = await api('GET', `/api/weddings/${wid}/rsvp`);
     469    if (rsvpR && rsvpR.ok) RSVPS = await rsvpR.json();
    445470    renderApp();
    446471}
     
    473498        a.classList.toggle('active', page ? `/${page}` === ctx : false);
    474499    });
     500    // Render wedding selector in the topbar actions so user can switch between weddings
     501    try {
     502        const topbarActions = document.querySelector('.topbar .topbar-actions');
     503        // remove old selector if present
     504        const oldSel = document.getElementById('weddingSelect');
     505        if (oldSel) oldSel.remove();
     506        const oldBtn = document.getElementById('btnNewWedding');
     507        if (oldBtn) oldBtn.remove();
     508
     509        // Create selector
     510        const sel = document.createElement('select');
     511        sel.id = 'weddingSelect';
     512        sel.style.minWidth = '200px';
     513        sel.style.padding = '6px 8px';
     514        sel.style.borderRadius = '8px';
     515        sel.style.border = '1px solid var(--border)';
     516        sel.style.background = '#fff';
     517        sel.style.marginRight = '8px';
     518        const noneOpt = document.createElement('option');
     519        noneOpt.value = '';
     520        noneOpt.textContent = '— Select Wedding —';
     521        sel.appendChild(noneOpt);
     522        WEDDINGS.forEach(w => {
     523            const o = document.createElement('option');
     524            o.value = w.wedding_id;
     525            o.textContent = `${w.date} ${w.notes?'- '+(w.notes||'') : ''}`;
     526            if (CURRENT_WEDDING && String(CURRENT_WEDDING.wedding_id) === String(w.wedding_id)) o.selected = true;
     527            sel.appendChild(o);
     528        });
     529        sel.addEventListener('change', async (e) => {
     530            const val = e.target.value;
     531            if (!val) return;
     532            const found = WEDDINGS.find(x => String(x.wedding_id) === String(val));
     533            if (found) {
     534                CURRENT_WEDDING = found;
     535                await reload();
     536                renderApp();
     537            }
     538        });
     539        topbarActions.insertBefore(sel, topbarActions.firstChild);
     540
     541        // New wedding button
     542        const newBtn = document.createElement('button');
     543        newBtn.id = 'btnNewWedding';
     544        newBtn.className = 'btn btn-sm';
     545        newBtn.style.marginRight = '8px';
     546        newBtn.textContent = '+ Wedding';
     547        newBtn.addEventListener('click', openCreateWeddingModal);
     548        topbarActions.insertBefore(newBtn, topbarActions.firstChild);
     549    } catch (e) { /* ignore UI insertion errors */ }
    475550    const content = document.getElementById('page-content');
    476551    content.innerHTML = '';
     
    893968    if(!CURRENT_WEDDING){ renderNoWedding(container); return; }
    894969    const wrap = document.createElement('div');
    895     wrap.innerHTML = `<div class="card"><div class="card-header"><h3>Guest List</h3><div class="card-actions"><button class="btn btn-primary" id="btnAddGuest">+ Add Guest</button></div></div><div id="guestTableContainer"></div></div>`;
     970    wrap.innerHTML = `<div class="card"><div class="card-header"><h3>Guest List</h3><div class="card-actions"><button class="btn btn-primary" id="btnAddGuest">+ Add Guest</button><button class="btn" id="btnUploadGuests">Upload CSV</button></div></div><div id="guestTableContainer"></div></div>`;
    896971    container.appendChild(wrap);
    897972    wrap.querySelector('#btnAddGuest').addEventListener('click', openAddGuestModal);
    898 
    899     const guestRsvpMap = {};
    900     if(EVENTS.length > 0){
    901         const firstEvent = EVENTS[0];
    902         // RSVP mapping removed for revert
     973    wrap.querySelector('#btnUploadGuests').addEventListener('click', () => {
     974        if(!CURRENT_WEDDING){ showToast('Select or create a wedding first.','danger'); return; }
     975        const input = document.createElement('input');
     976        input.type = 'file';
     977        input.accept = '.csv,text/csv';
     978        input.addEventListener('change', async (e) => {
     979            const file = e.target.files && e.target.files[0];
     980            if(!file) return;
     981            const text = await file.text();
     982            const rows = parseCSV(text);
     983            if(rows.length === 0){ showToast('No guests found in CSV.','danger'); return; }
     984
     985            const previewRows = rows.slice(0,200).map(r=>`<tr><td>${escapeHtml(r.first_name||'')}</td><td>${escapeHtml(r.last_name||'')}</td><td>${escapeHtml(r.email||'')}</td><td>${escapeHtml(r.role||'')}</td></tr>`).join('');
     986            const body = `<div style="max-height:320px;overflow:auto;"><table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:6px">First</th><th style="text-align:left;padding:6px">Last</th><th style="text-align:left;padding:6px">Email</th><th style="text-align:left;padding:6px">Role</th></tr></thead><tbody>${previewRows}</tbody></table></div><div style="margin-top:12px;"><label><input type="checkbox" id="csvSendEmails"/> Send invitation emails</label></div><p class="muted" style="margin-top:10px;">${rows.length} guests parsed. Showing first ${Math.min(rows.length,200)} rows.</p>`;
     987
     988            showModal('Import Guests from CSV', body, async () => {
     989                const sendEmails = document.getElementById('csvSendEmails').checked;
     990                const res = await api('POST', `/api/weddings/${CURRENT_WEDDING.wedding_id}/guests/bulk`, { guests: rows, sendEmails });
     991                if(res && res.ok){ const d = await res.json(); closeModal(); showToast(`${d.inserted} guests imported.`,'success'); await reload(); }
     992                else { const d = await res.json().catch(()=>null); showToast((d&&d.error) || 'Error importing guests.','danger'); }
     993            });
     994        });
     995        input.click();
     996    });
     997
     998    // Build table after ensuring we have up-to-date RSVP data for this wedding
     999    (async () => {
     1000        // Fetch fresh RSVPs for this wedding to ensure the UI reflects guest-submitted responses
     1001        try {
     1002            const r = await api('GET', `/api/weddings/${CURRENT_WEDDING.wedding_id}/rsvp`);
     1003            if (r && r.ok) RSVPS = await r.json();
     1004        } catch (e) { /* ignore, we'll use cached RSVPS if available */ }
     1005
     1006        const guestRsvpMap = {};
     1007        if (EVENTS.length > 0) {
     1008            const firstEvent = EVENTS[0];
     1009            RSVPS.forEach(rv => {
     1010                if (String(rv.event_id) === String(firstEvent.event_id)) guestRsvpMap[String(rv.guest_id)] = rv.status;
     1011            });
     1012        }
     1013
     1014        buildDataTable({
     1015            container: wrap.querySelector('#guestTableContainer'),
     1016            columns: [
     1017                { header:'First Name', accessor:'first_name' },
     1018                { header:'Last Name', accessor:'last_name' },
     1019                { header:'Email', accessor:'email' },
     1020                { header:'Status', accessor:'status', render: v => {
     1021                    const color = v === 'accepted' ? 'badge-success' : v === 'declined' ? 'badge-danger' : v === 'invited' ? 'badge-warn' : 'badge-purple';
     1022                    return `<span class="badge ${color}">${v || 'pending'}</span>`;
     1023                }},
     1024                { header:'Invite', accessor:'email', sortable:false, render: (v,row) =>
     1025                    v ? `<span class="badge badge-success">✉ Invite sent</span>` : `<span class="badge badge-warn">No email</span>`
     1026                }
     1027            ],
     1028            rows: GUESTS.map(g=>({ id:g.guest_id, first_name:g.first_name, last_name:g.last_name, email:g.email||'', status: guestRsvpMap[String(g.guest_id)] || 'pending' })),
     1029            onEdit: row => editGuestModal(row.id),
     1030            onDelete: async row => {
     1031                const res = await api('DELETE',`/api/guests/${row.id}`);
     1032                if(res && res.ok){ showToast('Guest removed.',''); await reload(); }
     1033                else showToast('Could not remove guest.','danger');
     1034            }
     1035        });
     1036    })();
     1037}
     1038
     1039// CSV helpers for guest import
     1040function splitCSVLine(line){
     1041    const out = [];
     1042    let cur = '';
     1043    let inQuotes = false;
     1044    for(let i=0;i<line.length;i++){
     1045        const ch = line[i];
     1046        if(ch === '"'){
     1047            if(inQuotes && line[i+1] === '"'){ cur += '"'; i++; }
     1048            else inQuotes = !inQuotes;
     1049        } else if(ch === ',' && !inQuotes){ out.push(cur); cur = ''; }
     1050        else cur += ch;
    9031051    }
    904 
    905     buildDataTable({
    906         container: wrap.querySelector('#guestTableContainer'),
    907         columns: [
    908             { header:'First Name', accessor:'first_name' },
    909             { header:'Last Name', accessor:'last_name' },
    910             { header:'Email', accessor:'email' },
    911             { header:'Status', accessor:'status', render: v => {
    912                 const color = v === 'accepted' ? 'badge-success' : v === 'declined' ? 'badge-danger' : v === 'invited' ? 'badge-warn' : 'badge-purple';
    913                 return `<span class="badge ${color}">${v || 'pending'}</span>`;
    914             }},
    915             { header:'Invite', accessor:'email', sortable:false, render: (v,row) =>
    916                 v ? `<span class="badge badge-success">✉ Invite sent</span>` : `<span class="badge badge-warn">No email</span>`
    917             }
    918         ],
    919         rows: GUESTS.map(g=>({ id:g.guest_id, first_name:g.first_name, last_name:g.last_name, email:g.email||'', status: 'pending' })),
    920         onEdit: row => editGuestModal(row.id),
    921         onDelete: async row => {
    922             const res = await api('DELETE',`/api/guests/${row.id}`);
    923             if(res && res.ok){ showToast('Guest removed.',''); await reload(); }
    924             else showToast('Could not remove guest.','danger');
    925         }
    926     });
    927 }
     1052    out.push(cur);
     1053    return out.map(s=>s.trim());
     1054}
     1055
     1056function parseCSV(text){
     1057    if(!text) return [];
     1058    const lines = text.split(/\r?\n/).map(l=>l.trim()).filter(l=>l.length>0);
     1059    if(lines.length === 0) return [];
     1060    const first = splitCSVLine(lines[0]).map(h=>h.toLowerCase());
     1061    const hasHeader = first.some(h=>['first','first_name','first name','last','last_name','email','role'].includes(h));
     1062    const rows = [];
     1063    const start = hasHeader ? 1 : 0;
     1064    let headers = first;
     1065    if(!hasHeader) headers = ['first_name','last_name','email','role'];
     1066    for(let i=start;i<lines.length;i++){
     1067        const cols = splitCSVLine(lines[i]);
     1068        const obj = {};
     1069        for(let j=0;j<headers.length;j++){
     1070            const key = headers[j] ? headers[j].toLowerCase().replace(/ /g,'_') : `col${j}`;
     1071            obj[key] = cols[j] ? cols[j].trim().replace(/^"|"$/g,'') : '';
     1072        }
     1073        const out = {
     1074            first_name: obj.first_name || obj.first || '',
     1075            last_name: obj.last_name || obj.last || '',
     1076            email: obj.email || '',
     1077            role: obj.role || ''
     1078        };
     1079        if(out.first_name || out.last_name) rows.push(out);
     1080    }
     1081    return rows;
     1082}
     1083
     1084function escapeHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
    9281085
    9291086function openAddGuestModal(){
     
    15681725//  UTILITY
    15691726// =============================================================
     1727// When the window gains focus, refresh wedding-specific data so changes submitted via RSVP links
     1728// (which happen outside this UI) are reflected in both Guests and Seating pages.
     1729window.addEventListener('focus', () => { if (CURRENT_WEDDING) reload(); });
     1730
    15701731function focusSearch(){
    15711732    const s = document.querySelector('.search-input');
  • server.js

    r3679996 rb7d8a4d  
    6161    port: parseInt(process.env.DB_PORT, 10),
    6262    ssl: { rejectUnauthorized: false }
     63});
     64
     65// POST /api/weddings/:wid/guests/bulk — add multiple guests in a transaction
     66app.post('/api/weddings/:wid/guests/bulk', requireAuth, async (req, res) => {
     67    const { guests, sendEmails } = req.body;
     68    if (!Array.isArray(guests) || guests.length === 0) return res.status(400).json({ error: 'guests array is required.' });
     69
     70    const wid = req.params.wid;
     71    const client = await pool.connect();
     72    try {
     73        // Ensure wedding exists
     74        const wRes = await client.query('SET search_path TO project; SELECT wedding_id, "date" FROM wedding WHERE wedding_id=$1', [wid]);
     75        if (wRes.rows.length === 0) {
     76            client.release();
     77            return res.status(404).json({ error: 'Wedding not found.' });
     78        }
     79
     80        // Fetch events for this wedding
     81        const evRes = await client.query('SELECT event_id, event_type, "date", start_time, end_time FROM project.event WHERE wedding_id=$1 ORDER BY "date", start_time', [wid]);
     82        const events = evRes.rows;
     83
     84        await client.query('BEGIN');
     85        const inserted = [];
     86        for (const g of guests) {
     87            const first_name = (g.first_name || '').trim();
     88            const last_name = (g.last_name || '').trim();
     89            const email = (g.email || null);
     90            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            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            const guest = ins.rows[0];
     97            inserted.push(guest);
     98
     99            // Create RSVP records for each event
     100            for (const ev of events) {
     101                try {
     102                    await client.query(
     103                        `INSERT INTO project.event_rsvp(status, response_date, guest_id, event_id)
     104                         VALUES ('invited', CURRENT_DATE, $1, $2)
     105                         ON CONFLICT (guest_id, event_id) DO NOTHING`,
     106                        [guest.guest_id, ev.event_id]
     107                    );
     108                } catch (e) {
     109                    // Log and continue
     110                    console.warn('Could not create RSVP for guest', guest.guest_id, 'event', ev.event_id, e.message);
     111                }
     112            }
     113        }
     114
     115        await client.query('COMMIT');
     116
     117        // Optionally send emails after commit
     118        if (sendEmails) {
     119            for (const guest of inserted) {
     120                if (!guest.email) continue;
     121                try {
     122                    // Build event links
     123                    const eventLinks = events.map(ev => {
     124                        const token = crypto.createHmac('sha256', 'wedding_rsvp_secret').update(`${guest.guest_id}-${ev.event_id}`).digest('hex');
     125                        const base = `http://localhost:${PORT}`;
     126                        return `
     127                            <tr>
     128                              <td style="padding:8px 0; font-size:15px; color:#2c1f1f;">
     129                                <strong>${ev.event_type}</strong> — ${ev.date} ${ev.start_time}–${ev.end_time}
     130                              </td>
     131                              <td style="padding:8px 0; text-align:right;">
     132                                <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=accepted" style="background:#3a9e6b;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;margin-right:6px;font-size:13px;">✅ Accept</a>
     133                                <a href="${base}/rsvp.html?guest=${guest.guest_id}&event=${ev.event_id}&token=${token}&action=declined" style="background:#c94545;color:#fff;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;">❌ Decline</a>
     134                              </td>
     135                            </tr>`;
     136                    }).join('');
     137
     138                    const mailOptions = {
     139                        from: `"Wedding Planner" <${process.env.EMAIL_USER}>`,
     140                        to: guest.email,
     141                        subject: `You're Invited! 💍 ${req.session.user.first_name} & ${req.session.user.last_name}'s Wedding`,
     142                        html: `
     143                            <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;">
     144                              <div style="background:linear-gradient(135deg,#d4606a,#e8949c);padding:32px;text-align:center;">
     145                                <div style="font-size:36px;margin-bottom:8px;">💍</div>
     146                                <h1 style="color:#fff;font-size:26px;margin:0;">You're Invited!</h1>
     147                                <p style="color:rgba(255,255,255,0.9);margin:8px 0 0;">Wedding Invitation</p>
     148                              </div>
     149                              <div style="padding:32px;">
     150                                <p style="font-size:16px;color:#2c1f1f;">Dear <strong>${guest.first_name} ${guest.last_name}</strong>,</p>
     151                                <p style="font-size:15px;color:#6b4f4f;line-height:1.6;">Please RSVP for the events below:</p>
     152                                <table style="width:100%;border-collapse:collapse;">${eventLinks}</table>
     153                              </div>
     154                              <div style="background:#f2e8db;padding:16px;text-align:center;"></div>
     155                            </div>`
     156                    };
     157                    await transporter.sendMail(mailOptions);
     158                } catch (mailErr) {
     159                    console.warn('Failed to send invite to', guest.email, mailErr.message);
     160                }
     161            }
     162        }
     163
     164        client.release();
     165        res.json({ inserted: inserted.length, guests: inserted });
     166    } catch (err) {
     167        try { await client.query('ROLLBACK'); } catch (e) { /* ignore */ }
     168        client.release();
     169        console.error('Bulk guest insert error:', err.message);
     170        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
     171        res.status(500).json({ error: 'Server error during bulk guest insert.' });
     172    }
    63173});
    64174
     
    83193
    84194// =============================================================
     195//  Service layer: Booking validation utilities
     196// =============================================================
     197class ApiError extends Error {
     198    constructor(status, message) {
     199        super(message);
     200        this.status = status;
     201    }
     202}
     203
     204const BookingService = {
     205    // Normalize date value (DATE from PG may be string or Date)
     206    _fmtDate(d) {
     207        if (!d) return null;
     208        if (d instanceof Date) return d.toISOString().slice(0,10);
     209        // assume string YYYY-MM-DD
     210        return String(d).slice(0,10);
     211    },
     212
     213    // Check if two time intervals overlap: [s1,e1) and [s2,e2)
     214    isOverlapping(s1, e1, s2, e2) {
     215        if (!s1 || !e1 || !s2 || !e2) return false;
     216        // times are strings like HH:MM:SS or HH:MM
     217        const toSec = t => {
     218            const parts = String(t).split(':').map(Number);
     219            return (parts[0]||0)*3600 + (parts[1]||0)*60 + (parts[2]||0);
     220        };
     221        const a1 = toSec(s1), b1 = toSec(e1), a2 = toSec(s2), b2 = toSec(e2);
     222        return a1 < b2 && a2 < b1;
     223    },
     224
     225    // Gather all bookings/events for a given wedding and date
     226    async getBookingsForWeddingDate(wedding_id, date) {
     227        const q = `
     228            SELECT 'venue' AS type, booking_id AS id, start_time, end_time
     229            FROM project.venue_booking WHERE wedding_id=$1 AND "date"=$2
     230            UNION ALL
     231            SELECT 'church' AS type, booking_id AS id, start_time, end_time
     232            FROM project.church_booking WHERE wedding_id=$1 AND "date"=$2
     233            UNION ALL
     234            SELECT 'registrar' AS type, booking_id AS id, start_time, end_time
     235            FROM project.registrar_booking WHERE wedding_id=$1 AND "date"=$2
     236            UNION ALL
     237            SELECT 'photographer' AS type, booking_id AS id, start_time, end_time
     238            FROM project.photographer_booking WHERE wedding_id=$1 AND "date"=$2
     239            UNION ALL
     240            SELECT 'band' AS type, booking_id AS id, start_time, end_time
     241            FROM project.band_booking WHERE wedding_id=$1 AND "date"=$2
     242            UNION ALL
     243            SELECT 'event' AS type, event_id AS id, start_time, end_time
     244            FROM project.event WHERE wedding_id=$1 AND "date"=$2
     245        `;
     246        const res = await pool.query(q, [wedding_id, date]);
     247        return res.rows;
     248    },
     249
     250    // Validate booking date equals wedding date
     251    async validateDateMatchesWedding(wedding_id, date) {
     252        // 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]);
     254        if (r.rows.length === 0) {
     255            // Determine whether wedding not found or date mismatch
     256            const exists = await pool.query('SELECT 1 FROM project.wedding WHERE wedding_id=$1', [wedding_id]);
     257            if (exists.rows.length === 0) throw new ApiError(404, 'Wedding not found.');
     258            throw new ApiError(400, 'Booking is only allowed on the wedding date.');
     259        }
     260    },
     261
     262    // Validate time slot overlap across all bookings for the wedding/date
     263    // exclude optional { type, id } to allow updating existing record
     264    async validateNoOverlap(wedding_id, date, start_time, end_time, exclude) {
     265        const bookings = await BookingService.getBookingsForWeddingDate(wedding_id, date);
     266        for (const b of bookings) {
     267            if (exclude && String(exclude.type) === String(b.type) && String(exclude.id) === String(b.id)) continue;
     268            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.');
     270            }
     271        }
     272    },
     273
     274    // Registrar location must match wedding venue location for that wedding date
     275    // We require an existing venue booking on the wedding date and compare locations
     276    async validateRegistrarLocation(wedding_id, date, registrar_id) {
     277        // find registrar
     278        const r = await pool.query('SELECT location FROM project.registrar WHERE registrar_id=$1', [registrar_id]);
     279        if (r.rows.length === 0) throw new ApiError(404, 'Registrar not found.');
     280        const regLoc = r.rows[0].location;
     281
     282        // 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]);
     284        if (vb.rows.length === 0) {
     285            // no venue booked on that date -> not allowed
     286            throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.');
     287        }
     288        // require at least one matching venue location
     289        const match = vb.rows.some(x => String(x.location).trim().toLowerCase() === String(regLoc).trim().toLowerCase());
     290        if (!match) throw new ApiError(400, 'Registrar booking is only allowed at the wedding venue location.');
     291    }
     292};
     293
     294// =============================================================
    85295//  EMAIL TRANSPORTER
    86296// =============================================================
     
    323533        return res.status(400).json({ error: 'event_type, date, start_time, end_time are required.' });
    324534    try {
     535        // Business validations
     536        await BookingService.validateDateMatchesWedding(req.params.wid, date);
     537        await BookingService.validateNoOverlap(req.params.wid, date, start_time, end_time, null);
     538
    325539        const result = await pool.query(
    326540            `INSERT INTO project.event(event_type,"date",start_time,end_time,status,wedding_id)
     
    331545    } catch (err) {
    332546        console.error(err.message);
     547        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    333548        res.status(500).json({ error: 'Server error.' });
    334549    }
     
    339554    const { event_type, date, start_time, end_time, status } = req.body;
    340555    try {
     556        // fetch existing event to know wedding_id and current values
     557        const evRes = await pool.query('SELECT * FROM project.event WHERE event_id=$1', [req.params.id]);
     558        if (evRes.rows.length === 0) return res.status(404).json({ error: 'Event not found.' });
     559        const ev = evRes.rows[0];
     560        const weddingId = ev.wedding_id;
     561        // Validate date matches wedding and no overlap (exclude this event)
     562        await BookingService.validateDateMatchesWedding(weddingId, date);
     563        await BookingService.validateNoOverlap(weddingId, date, start_time, end_time, { type: 'event', id: req.params.id });
     564
    341565        const result = await pool.query(
    342566            `UPDATE project.event SET event_type=$1,"date"=$2,start_time=$3,end_time=$4,status=$5
     
    348572    } catch (err) {
    349573        console.error(err.message);
     574        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    350575        res.status(500).json({ error: 'Server error.' });
    351576    }
     
    7781003    // Check availability: no overlapping booking for same venue on same date
    7791004    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
    7801009        const conflict = await pool.query(
    7811010            `SELECT booking_id FROM project.venue_booking
     
    7951024    } catch (err) {
    7961025        console.error(err.message);
     1026        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    7971027        res.status(500).json({ error: 'Server error.' });
    7981028    }
     
    8481078        return res.status(400).json({ error: 'date, start_time, end_time and band_id are required.' });
    8491079    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);
    8501083        const result = await pool.query(
    8511084            `INSERT INTO project.band_booking("date",start_time,end_time,status,band_id,wedding_id)
     
    8561089    } catch (err) {
    8571090        console.error(err.message);
     1091        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    8581092        res.status(500).json({ error: 'Server error.' });
    8591093    }
     
    9091143        return res.status(400).json({ error: 'date, start_time, end_time and photographer_id are required.' });
    9101144    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);
    9111148        const result = await pool.query(
    9121149            `INSERT INTO project.photographer_booking("date",start_time,end_time,status,photographer_id,wedding_id)
     
    9171154    } catch (err) {
    9181155        console.error(err.message);
     1156        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    9191157        res.status(500).json({ error: 'Server error.' });
    9201158    }
     
    9771215        return res.status(400).json({ error: 'date, start_time, end_time and church_id are required.' });
    9781216    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);
    9791220        const result = await pool.query(
    9801221            `INSERT INTO project.church_booking("date",start_time,end_time,status,church_id,wedding_id)
     
    9851226    } catch (err) {
    9861227        console.error(err.message);
     1228        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    9871229        res.status(500).json({ error: 'Server error.' });
    9881230    }
     
    10381280        return res.status(400).json({ error: 'date, start_time, end_time and registrar_id are required.' });
    10391281    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
    10401287        const result = await pool.query(
    10411288            `INSERT INTO project.registrar_booking("date",start_time,end_time,status,registrar_id,wedding_id)
     
    10461293    } catch (err) {
    10471294        console.error(err.message);
     1295        if (err instanceof ApiError) return res.status(err.status).json({ error: err.message });
    10481296        res.status(500).json({ error: 'Server error.' });
    10491297    }
Note: See TracChangeset for help on using the changeset viewer.