/**
* 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']
)
}