← back to NationalPaperHangers
Reject bookings that fall outside the installer's published hours
aaf1edb0d1b5d25995a8c252c0d2a9dc700bd349 · 2026-05-19 07:52:51 -0700 · Steve
Files touched
M lib/slots.jsM routes/api.js
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 →