[object Object]

← back to NationalPaperHangers

Reject bookings that fall outside the installer's published hours

aaf1edb0d1b5d25995a8c252c0d2a9dc700bd349 · 2026-05-19 07:52:51 -0700 · Steve

Files touched

Diff

commit aaf1edb0d1b5d25995a8c252c0d2a9dc700bd349
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 07:52:51 2026 -0700

    Reject bookings that fall outside the installer's published hours
---
 lib/slots.js  | 45 ++++++++++++++++++++++++++++++++++++++++++++-
 routes/api.js | 11 +++++++++++
 2 files changed, 55 insertions(+), 1 deletion(-)

diff --git a/lib/slots.js b/lib/slots.js
index d4c8196..260898e 100644
--- a/lib/slots.js
+++ b/lib/slots.js
@@ -151,4 +151,47 @@ async function availableSlots(installerId, opts = {}) {
   return allSlots.filter(s => DateTime.fromISO(s.start).setZone(tz) > now);
 }
 
-module.exports = { availableSlots };
+// Verify that a concrete [start,end] window lies entirely inside one of the
+// installer's recurring availability windows for that weekday, and does not
+// intersect any time-off block. Used by the booking POST so a directly-posted
+// scheduled_start/scheduled_end can't fall outside published hours (e.g. 3am
+// on a day the installer doesn't work). Conflict-with-other-bookings is
+// checked separately under the row lock.
+async function isWithinAvailability(installerId, startJsDate, endJsDate, opts = {}) {
+  const tz = opts.timezone || DEFAULT_TZ;
+  const start = DateTime.fromJSDate(startJsDate).setZone(tz);
+  const end   = DateTime.fromJSDate(endJsDate).setZone(tz);
+  if (!start.isValid || !end.isValid || end <= start) return false;
+  // A booking must not straddle a calendar day — availability windows are
+  // per-weekday, so a cross-midnight request can't be inside any one window.
+  if (!start.hasSame(end, 'day')) return false;
+
+  const avail = await getAvailability(installerId);
+  if (avail.length === 0) return false;
+
+  const dow = start.weekday === 7 ? 0 : start.weekday;
+  const todayWindows = avail.filter(a => a.day_of_week === dow);
+  if (todayWindows.length === 0) return false;
+
+  const insideWindow = todayWindows.some(w => {
+    const [sh, sm] = w.start_time.split(':').map(Number);
+    const [eh, em] = w.end_time.split(':').map(Number);
+    const winStart = start.set({ hour: sh, minute: sm, second: 0, millisecond: 0 });
+    const winEnd   = start.set({ hour: eh, minute: em, second: 0, millisecond: 0 });
+    return start >= winStart && end <= winEnd;
+  });
+  if (!insideWindow) return false;
+
+  const timeOff = await getTimeOff(installerId, start.toUTC().toISO(), end.toUTC().toISO());
+  const requested = Interval.fromDateTimes(start, end);
+  const blockedByTimeOff = timeOff.some(t => {
+    const iv = Interval.fromDateTimes(
+      DateTime.fromJSDate(t.start_at).setZone(tz),
+      DateTime.fromJSDate(t.end_at).setZone(tz)
+    );
+    return iv.isValid && iv.overlaps(requested);
+  });
+  return !blockedByTimeOff;
+}
+
+module.exports = { availableSlots, isWithinAvailability };
diff --git a/routes/api.js b/routes/api.js
index e2a73bf..59d247d 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -184,6 +184,17 @@ router.post('/installers/:slug/book', async (req, res, next) => {
       return res.status(409).json({ error: 'slot_taken' });
     }
 
+    // The conflict check above only proves the slot isn't double-booked. It
+    // does NOT prove the requested window is inside the installer's published
+    // hours — a direct POST could otherwise book 3am on a day they don't work,
+    // or land inside a time-off block. Reject anything outside availability.
+    const withinHours = await slots.isWithinAvailability(installer.id, start, end);
+    if (!withinHours) {
+      await client.query('ROLLBACK');
+      client.release();
+      return res.status(409).json({ error: 'slot_unavailable' });
+    }
+
     // Structured booking brief (UX idea #6) — validate enums, coerce numerics.
     const ALLOWED_SURFACE = new Set([
       'new_plaster','painted_drywall','wallpaper_to_remove','brick','wood_panel','other'

← 47d0f2e Guard booking lifecycle transitions against reviving termina  ·  back to NationalPaperHangers  ·  Validate admin portfolio uploads by magic bytes, not the spo a3b0896 →