OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / routes / availability.js

267 lines · javascript ref:
/**
 * Availability routes — provider schedule + slot calculation.
 */

import { json, readJson, notFound } from '../response.js'
import { requireAuth } from './auth-helpers.js'

const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/

function validTime (s) { return typeof s === 'string' && TIME_RE.test(s) }
function validDate (s) { return typeof s === 'string' && DATE_RE.test(s) }

export function registerAvailabilityRoutes (router, server) {
  // Get provider availability (public — needed for slot calculation)
  router.get('/api/providers/:slug/availability', async (req, res) => {
    const providerIdEntry = await server.db.get(`provider-by-slug:${req.params.slug}`)
    if (!providerIdEntry) return notFound(res, 'Provider not found')

    const entry = await server.db.get(`availability:${providerIdEntry.value}`)
    if (!entry) {
      json(res, { weekly: {}, bufferMinutes: 15, advanceBooking: { minHours: 24, maxDays: 60 } })
      return
    }

    json(res, entry.value)
  })

  // Update availability (provider)
  router.put('/api/availability', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const body = await readJson(req)

    const { providerId, weekly, bufferMinutes, advanceBooking, cancellation, reschedule, noShow, timezone } = body
    if (!providerId) {
      json(res, { error: 'providerId required' }, 400)
      return
    }

    // Verify ownership
    const providerEntry = await server.db.get(`provider:${providerId}`)
    if (!providerEntry) return notFound(res, 'Provider not found')
    if (providerEntry.value.userId !== auth.userId &&
        !auth.permissions.admin) {
      json(res, { error: 'Not your provider' }, 403)
      return
    }

    // Validate bufferMinutes
    if (bufferMinutes != null && (typeof bufferMinutes !== 'number' || bufferMinutes < 0 || bufferMinutes > 120)) {
      return json(res, { error: 'bufferMinutes must be 0-120' }, 400)
    }

    // Validate weekly time windows
    if (weekly) {
      for (const [day, windows] of Object.entries(weekly)) {
        if (!Array.isArray(windows)) continue
        for (const w of windows) {
          if (!validTime(w.start) || !validTime(w.end)) {
            return json(res, { error: `Invalid time format in ${day}: use HH:MM (e.g. "09:00")` }, 400)
          }
          if (w.start >= w.end) {
            return json(res, { error: `${day}: start must be before end` }, 400)
          }
        }
      }
    }

    const availability = {
      providerId,
      timezone: timezone || providerEntry.value.timezone || 'America/New_York',
      weekly: weekly || {},
      bufferMinutes: bufferMinutes != null ? bufferMinutes : 15,
      advanceBooking: advanceBooking || { minHours: 24, maxDays: 60 },
      cancellation: cancellation || { windowHours: 24, refundPolicy: 'full' },
      reschedule: reschedule || { windowHours: 24, maxPerBooking: 2 },
      noShow: noShow || { penalty: 'forfeit' },
      updatedAt: Date.now()
    }

    await server.db.put(`availability:${providerId}`, availability)
    json(res, availability)
  })

  // Update date overrides (provider)
  router.put('/api/availability/overrides', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const body = await readJson(req)
    const { providerId, overrides } = body

    if (!providerId || !Array.isArray(overrides)) {
      json(res, { error: 'providerId and overrides array required' }, 400)
      return
    }

    await server.db.put(`availability-overrides:${providerId}`, overrides)
    json(res, { ok: true, count: overrides.length })
  })

  // Get available slots for a date range (public)
  router.get('/api/providers/:slug/slots', async (req, res) => {
    const providerIdEntry = await server.db.get(`provider-by-slug:${req.params.slug}`)
    if (!providerIdEntry) return notFound(res, 'Provider not found')

    const providerId = providerIdEntry.value
    const url = new URL('http://x' + req.url)
    const date = url.searchParams.get('date')
    const weekOf = url.searchParams.get('weekOf')
    const duration = parseInt(url.searchParams.get('duration') || '60')
    const tz = url.searchParams.get('tz') || 'UTC'

    if (!date && !weekOf) {
      return json(res, { error: 'date or weekOf query param required' }, 400)
    }
    if (date && !validDate(date)) {
      return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
    }
    if (weekOf && !validDate(weekOf)) {
      return json(res, { error: 'Invalid weekOf format: use YYYY-MM-DD' }, 400)
    }
    if (duration < 1 || duration > 480 || isNaN(duration)) {
      return json(res, { error: 'duration must be 1-480 minutes' }, 400)
    }

    if (weekOf) {
      // Return slots for 7 days
      const days = {}
      for (let i = 0; i < 7; i++) {
        const d = addDays(weekOf, i)
        const externalBusy = await getExternalBusy(server, providerId, d)
        days[d] = await calculateSlots(server.db, providerId, d, duration, externalBusy)
      }
      json(res, { days })
    } else {
      const externalBusy = await getExternalBusy(server, providerId, date)
      const slots = await calculateSlots(server.db, providerId, date, duration, externalBusy)
      json(res, { date, slots })
    }
  })
}

// ---- Slot Calculation ----

export async function calculateSlots (db, providerId, date, duration, externalBusy = []) {
  const availEntry = await db.get(`availability:${providerId}`)
  if (!availEntry) return []
  const avail = availEntry.value

  const overridesEntry = await db.get(`availability-overrides:${providerId}`)
  const overrides = overridesEntry ? overridesEntry.value : []

  // Check for date override
  const override = overrides.find(o => o.date === date)

  let windows
  if (override) {
    if (!override.available) return []  // day off
    windows = override.windows
  } else {
    const dayOfWeek = getDayOfWeek(date)
    windows = avail.weekly[dayOfWeek]
    if (!windows || windows.length === 0) return []
  }

  // Check advance booking limits
  const now = Date.now()
  const dateMs = new Date(date + 'T00:00:00Z').getTime()
  const hoursUntil = (dateMs - now) / 3600000

  if (avail.advanceBooking) {
    if (hoursUntil < (avail.advanceBooking.minHours || 0)) return []
    if (hoursUntil / 24 > (avail.advanceBooking.maxDays || 365)) return []
  }

  // Generate all possible slots from windows
  const bufferMs = (avail.bufferMinutes || 0) * 60000
  const durationMs = duration * 60000
  const allSlots = []

  for (const window of windows) {
    const startMs = timeToMs(window.start)
    const endMs = timeToMs(window.end)

    let slotStart = startMs
    while (slotStart + durationMs <= endMs) {
      allSlots.push({
        start: slotStart,
        end: slotStart + durationMs,
        startTime: msToTime(slotStart),
        endTime: msToTime(slotStart + durationMs)
      })
      slotStart += durationMs + bufferMs
    }
  }

  // Remove slots that conflict with existing bookings
  const bookings = await getBookingsForDate(db, providerId, date)

  let availableSlots = allSlots.filter(slot => {
    return !bookings.some(b => {
      if (b.status === 'cancelled') return false
      const bStart = timeToMs(b.startTimeLocal) - bufferMs
      const bEnd = timeToMs(b.endTimeLocal) + bufferMs
      return slot.start < bEnd && slot.end > bStart
    })
  })

  // Filter out slots that conflict with external busy blocks (e.g., Google Calendar)
  if (externalBusy.length > 0) {
    availableSlots = availableSlots.filter(slot => {
      return !externalBusy.some(busy => slot.start < busy.end && slot.end > busy.start)
    })
  }

  return availableSlots
}

async function getBookingsForDate (db, providerId, date) {
  const bookings = []
  for await (const entry of db.createReadStream({
    gte: `bookings-by-date:${providerId}:${date}:`,
    lte: `bookings-by-date:${providerId}:${date}:~`
  })) {
    const bookingEntry = await db.get(`booking:${entry.value}`)
    if (bookingEntry) bookings.push(bookingEntry.value)
  }
  return bookings
}

function getDayOfWeek (dateStr) {
  const d = new Date(dateStr + 'T12:00:00Z')
  const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
  return days[d.getUTCDay()]
}

function timeToMs (timeStr) {
  const [h, m] = timeStr.split(':').map(Number)
  return (h * 60 + m) * 60000
}

function msToTime (ms) {
  const totalMin = Math.floor(ms / 60000)
  const h = Math.floor(totalMin / 60)
  const m = totalMin % 60
  return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
}

function addDays (dateStr, days) {
  const d = new Date(dateStr + 'T12:00:00Z')
  d.setUTCDate(d.getUTCDate() + days)
  return d.toISOString().split('T')[0]
}

/**
 * Get external busy blocks for a provider + date.
 * Currently supports Google Calendar. Returns [] if not configured.
 */
export async function getExternalBusy (server, providerId, date) {
  if (!server.gcal) return []
  const entry = await server.db.get(`gcal:provider:${providerId}`)
  if (!entry || !entry.value.connected) return []
  return server.gcal.getBusyBlocksForDate(
    server.db, providerId, date,
    entry.value.calendarIds || ['primary']
  )
}