/**
* 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
}