OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / routes / bookings.js

313 lines · javascript ref:
/**
 * Booking routes — create, confirm, cancel, reschedule.
 */

import { json, readJson, notFound } from '../response.js'
import { requireAuth, optionalAuth } from './auth-helpers.js'
import { calculateSlots, getExternalBusy } from './availability.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])$/

export function registerBookingRoutes (router, server) {
  // Create booking (authenticated client)
  router.post('/api/bookings', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const body = await readJson(req)

    const { providerId, packageId, date, startTime, endTime, displayName, email } = body

    if (!providerId || !packageId || !date || !startTime) {
      return json(res, { error: 'providerId, packageId, date, and startTime required' }, 400)
    }
    if (!DATE_RE.test(date)) {
      return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
    }
    if (!TIME_RE.test(startTime)) {
      return json(res, { error: 'Invalid startTime format: use HH:MM' }, 400)
    }
    if (endTime && !TIME_RE.test(endTime)) {
      return json(res, { error: 'Invalid endTime format: use HH:MM' }, 400)
    }

    // Get package
    const pkgEntry = await server.db.get(`package-detail:${packageId}`)
    if (!pkgEntry) return notFound(res, 'Package not found')
    const pkg = pkgEntry.value

    // Calculate end time from package duration
    const slotEnd = endTime || addMinutesToTime(startTime, pkg.sessionDuration)

    // Check slot availability (including Google Calendar busy blocks)
    const externalBusy = await getExternalBusy(server, providerId, date)
    const slots = await calculateSlots(server.db, providerId, date, pkg.sessionDuration, externalBusy)
    const slotAvailable = slots.some(s => s.startTime === startTime)

    if (!slotAvailable) {
      json(res, { error: 'Slot not available' }, 409)
      return
    }

    // Create or update client record
    const clientId = await getOrCreateClient(server, auth, displayName, email, providerId)

    // Create booking
    const booking = {
      id: server.generateId('bk'),
      providerId,
      clientId,
      packageId,
      purchaseId: null,
      date,
      startTimeLocal: startTime,
      endTimeLocal: slotEnd,
      status: pkg.price === 0 ? 'confirmed' : 'pending',
      rescheduleCount: 0,
      notes: '',
      createdAt: Date.now()
    }

    // If free (discovery call), confirm immediately
    if (pkg.price === 0) {
      booking.confirmedAt = Date.now()
    }

    // Write booking + indexes
    const batch = server.db.batch()
    await batch.put(`booking:${booking.id}`, booking)
    await batch.put(`bookings-by-date:${providerId}:${date}:${booking.id}`, booking.id)
    await batch.put(`bookings-by-client:${clientId}:${booking.id}`, booking.id)
    await batch.put(`bookings-by-provider:${providerId}:${booking.id}`, booking.id)
    await batch.flush()

    json(res, booking, 201)
  })

  // Get booking detail
  router.get('/api/bookings/:id', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const entry = await server.db.get(`booking:${req.params.id}`)
    if (!entry) return notFound(res, 'Booking not found')
    json(res, entry.value)
  })

  // Cancel booking
  router.put('/api/bookings/:id/cancel', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const entry = await server.db.get(`booking:${req.params.id}`)
    if (!entry) return notFound(res, 'Booking not found')

    const booking = entry.value

    // Check ownership (client or provider)
    const isClient = booking.clientId === auth.userId
    const isProvider = await isProviderOwner(server, booking.providerId, auth.userId)

    if (!isClient && !isProvider && !auth.permissions.admin) {
      json(res, { error: 'Not authorized' }, 403)
      return
    }

    booking.status = 'cancelled'
    booking.cancelledAt = Date.now()
    booking.cancelledBy = isProvider ? 'provider' : 'client'

    await server.db.put(`booking:${booking.id}`, booking)
    json(res, booking)
  })

  // Reschedule booking
  router.put('/api/bookings/:id/reschedule', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const body = await readJson(req)
    const { date, startTime } = body

    if (!date || !startTime) {
      return json(res, { error: 'date and startTime required' }, 400)
    }
    if (!DATE_RE.test(date)) {
      return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
    }
    if (!TIME_RE.test(startTime)) {
      return json(res, { error: 'Invalid startTime format: use HH:MM' }, 400)
    }

    const entry = await server.db.get(`booking:${req.params.id}`)
    if (!entry) return notFound(res, 'Booking not found')

    const booking = entry.value

    if (booking.status !== 'confirmed') {
      json(res, { error: 'Can only reschedule confirmed bookings' }, 400)
      return
    }

    // Get package for duration
    const pkgEntry = await server.db.get(`package-detail:${booking.packageId}`)
    const duration = pkgEntry ? pkgEntry.value.sessionDuration : 60
    const endTime = addMinutesToTime(startTime, duration)

    // Check reschedule limit
    const availEntry = await server.db.get(`availability:${booking.providerId}`)
    const maxReschedules = availEntry ? (availEntry.value.reschedule?.maxPerBooking || 2) : 2
    if (booking.rescheduleCount >= maxReschedules) {
      json(res, { error: 'Maximum reschedules reached' }, 400)
      return
    }

    // Check new slot availability (including Google Calendar busy blocks)
    const reschExternalBusy = await getExternalBusy(server, booking.providerId, date)
    const slots = await calculateSlots(server.db, booking.providerId, date, duration, reschExternalBusy)
    if (!slots.some(s => s.startTime === startTime)) {
      json(res, { error: 'New slot not available' }, 409)
      return
    }

    // Remove old date index
    const batch = server.db.batch()
    await batch.del(`bookings-by-date:${booking.providerId}:${booking.date}:${booking.id}`)

    // Update booking
    const oldDate = booking.date
    const oldTime = booking.startTimeLocal
    booking.previousTimes = booking.previousTimes || []
    booking.previousTimes.push({ date: oldDate, time: oldTime, rescheduledAt: Date.now() })
    booking.date = date
    booking.startTimeLocal = startTime
    booking.endTimeLocal = endTime
    booking.rescheduleCount++
    booking.rescheduledAt = Date.now()

    await batch.put(`booking:${booking.id}`, booking)
    await batch.put(`bookings-by-date:${booking.providerId}:${date}:${booking.id}`, booking.id)
    await batch.flush()

    json(res, booking)
  })

  // Mark booking complete (provider)
  router.put('/api/bookings/:id/complete', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const entry = await server.db.get(`booking:${req.params.id}`)
    if (!entry) return notFound(res, 'Booking not found')

    const booking = entry.value
    booking.status = 'completed'
    booking.completedAt = Date.now()

    await server.db.put(`booking:${booking.id}`, booking)
    json(res, booking)
  })

  // Add provider notes to booking
  router.put('/api/bookings/:id/notes', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const body = await readJson(req)
    const entry = await server.db.get(`booking:${req.params.id}`)
    if (!entry) return notFound(res, 'Booking not found')

    const booking = entry.value
    booking.notes = body.notes || ''

    await server.db.put(`booking:${booking.id}`, booking)
    json(res, booking)
  })

  // List bookings for provider (dashboard)
  router.get('/api/dashboard/bookings', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const url = new URL('http://x' + req.url)
    const providerId = url.searchParams.get('providerId')
    const date = url.searchParams.get('date')

    if (!providerId) {
      json(res, { error: 'providerId required' }, 400)
      return
    }

    const bookings = []

    if (date) {
      // Bookings for specific date
      for await (const entry of server.db.createReadStream({
        gte: `bookings-by-date:${providerId}:${date}:`,
        lte: `bookings-by-date:${providerId}:${date}:~`
      })) {
        const bEntry = await server.db.get(`booking:${entry.value}`)
        if (bEntry) bookings.push(bEntry.value)
      }
    } else {
      // All bookings for provider
      for await (const entry of server.db.createReadStream({
        gte: `bookings-by-provider:${providerId}:`,
        lte: `bookings-by-provider:${providerId}:~`
      })) {
        const bEntry = await server.db.get(`booking:${entry.value}`)
        if (bEntry) bookings.push(bEntry.value)
      }
    }

    json(res, bookings)
  })

  // Client portal — my bookings
  router.get('/api/portal/bookings', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const bookings = []

    for await (const entry of server.db.createReadStream({
      gte: `bookings-by-client:${auth.userId}:`,
      lte: `bookings-by-client:${auth.userId}:~`
    })) {
      const bEntry = await server.db.get(`booking:${entry.value}`)
      if (bEntry) bookings.push(bEntry.value)
    }

    json(res, bookings)
  })
}

// ---- Helpers ----

function addMinutesToTime (timeStr, minutes) {
  const [h, m] = timeStr.split(':').map(Number)
  const totalMin = h * 60 + m + minutes
  const newH = Math.floor(totalMin / 60)
  const newM = totalMin % 60
  return String(newH).padStart(2, '0') + ':' + String(newM).padStart(2, '0')
}

async function getOrCreateClient (server, auth, displayName, email, providerId) {
  const clientKey = `client-by-identity:${auth.identityId}`

  // Check-then-create with re-check after batch to handle races.
  // Two concurrent requests may both pass the first check, but the second
  // batch write is idempotent (same clientId derived from userId).
  const existing = await server.db.get(clientKey)
  if (existing) return existing.value

  const clientId = auth.userId || server.generateId('cli')
  const client = {
    id: clientId,
    identityId: auth.identityId,
    displayName: displayName || auth.username || 'Client',
    email: email || null,
    createdAt: Date.now()
  }

  const batch = server.db.batch()
  await batch.put(`client:${clientId}`, client)
  await batch.put(clientKey, clientId)
  await batch.put(`clients-by-provider:${providerId}:${clientId}`, clientId)
  await batch.flush()

  // Re-read to handle race — if another request wrote first, return theirs
  const recheck = await server.db.get(clientKey)
  return recheck ? recheck.value : clientId
}

async function isProviderOwner (server, providerId, userId) {
  const entry = await server.db.get(`provider:${providerId}`)
  return entry && entry.value.userId === userId
}