feat(scheduling): round-robin team event types

This commit is contained in:
Ronni Baslund
2026-06-07 09:14:08 +02:00
parent b9b4d56a2d
commit 95cbdc4e3d
7 changed files with 203 additions and 8 deletions
+43 -4
View File
@@ -26,6 +26,8 @@ interface EventType {
availabilityScheduleId: string
locationType: string
ignoreAllDayEvents: boolean
assignment?: 'single' | 'round_robin'
hostPool?: string[]
isActive: boolean
}
interface MinuteInterval { startMinute: number; endMinute: number }
@@ -85,6 +87,9 @@ const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${b
const selectedHostId = ref<string | null>(null)
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? null)
// Round-robin pool candidates: every other host in the tenant (the owning host is
// always implicitly part of the pool, so it is excluded from the selectable list).
const poolCandidates = computed(() => (hosts.value ?? []).filter((h) => h._id !== selectedHostId.value))
const detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
const eventTypes = ref<EventType[]>([])
@@ -289,6 +294,8 @@ const etForm = reactive({
availabilityScheduleId: '',
locationType: 'jitsi',
ignoreAllDayEvents: true,
assignment: 'single' as 'single' | 'round_robin',
hostPool: [] as string[],
})
const etEditingId = ref<string | null>(null)
const etSlugTouched = ref(false)
@@ -303,6 +310,7 @@ function openEt(et?: EventType) {
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
assignment: et.assignment ?? 'single', hostPool: [...(et.hostPool ?? [])],
})
etSlugTouched.value = true // don't auto-rewrite an existing slug
} else {
@@ -311,7 +319,7 @@ function openEt(et?: EventType) {
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
ignoreAllDayEvents: true,
ignoreAllDayEvents: true, assignment: 'single', hostPool: [],
})
etSlugTouched.value = false
}
@@ -325,13 +333,18 @@ async function submitEt() {
if (!selectedHostId.value || !etValid.value) return
etBusy.value = true
try {
// Only send a host pool for round-robin; single-host keeps the legacy shape.
const payload = {
...etForm,
hostPool: etForm.assignment === 'round_robin' ? [...etForm.hostPool] : [],
}
if (etEditingId.value) {
// slug is immutable after creation (public links would break) → omit it.
const { slug: _slug, ...patch } = { ...etForm }
const { slug: _slug, ...patch } = payload
await request(`${base.value}/event-types/${etEditingId.value}`, { method: 'PATCH', body: patch })
toast.ok('Event type updated')
} else {
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: { ...etForm } })
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: payload })
toast.ok('Event type created')
}
etOpen.value = false
@@ -587,7 +600,10 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
<p v-if="!availability.length" class="hint">Create an availability schedule first.</p>
<Card v-for="et in eventTypes" :key="et._id" class="item">
<div class="itemmain">
<div class="ititle">{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span></div>
<div class="ititle">
{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span>
<Badge v-if="et.assignment === 'round_robin'" tone="neutral">round-robin</Badge>
</div>
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
</div>
<div class="itemactions">
@@ -837,6 +853,29 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
<input type="checkbox" v-model="etForm.ignoreAllDayEvents" />
Ignore all-day events when checking availability
</label>
<label class="field"><Eyebrow>Host assignment</Eyebrow>
<select class="input" v-model="etForm.assignment">
<option value="single">Single host (this host)</option>
<option value="round_robin">Round-robin (share across a team)</option>
</select>
<span class="slughint">
<template v-if="etForm.assignment === 'round_robin'">
Bookings are spread across the selected team. Visitors see the combined free time of everyone in the pool; each booking is assigned to a free team member.
</template>
<template v-else>Every booking goes to {{ selectedHost?.displayName ?? 'this host' }}.</template>
</span>
</label>
<div v-if="etForm.assignment === 'round_robin'" class="field">
<Eyebrow>Team pool</Eyebrow>
<p class="slughint">{{ selectedHost?.displayName ?? 'This host' }} is always included. Add other hosts to share bookings with:</p>
<div class="poollist">
<label v-for="h in poolCandidates" :key="h._id" class="daytoggle">
<input type="checkbox" :value="h._id" v-model="etForm.hostPool" />
{{ h.displayName }} <span class="mute small">· {{ h.slug }}</span>
</label>
<p v-if="!poolCandidates.length" class="mute small">No other hosts in this tenant yet.</p>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
@@ -114,6 +114,15 @@ export class BookingsService {
input: ConfirmBookingInput,
opts: { rescheduledFromBookingId?: Types.ObjectId },
): Promise<BookingDocument> {
// Round-robin: choose the pooled host this booking lands on BEFORE anything is
// written, then run the rest of the (single-host) flow against that host by
// swapping ctx.host. Single-host event types skip this entirely and behave
// exactly as before.
if (ctx.eventType.assignment === 'round_robin') {
const chosen = await this.selectRoundRobinHost(ctx, input.startUtc)
ctx = { ...ctx, host: chosen }
}
const { host, eventType, tenant } = ctx
if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')
@@ -203,6 +212,62 @@ export class BookingsService {
return booking
}
/**
* Round-robin host selection (§Phase 3). Returns the pooled host this booking
* should be assigned to for the requested start time:
* 1. Resolve the active pool (owner always included).
* 2. Keep only hosts for whom the slot is genuinely free right now (live
* free/busy re-check via SlotService — the same authoritative check the
* single-host path uses, so we never assign to a host who is actually busy).
* 3. Among the free hosts, pick the least-recently-booked (fewest/oldest
* bookings first) to spread load; ties fall back to the first in pool order.
* Throws Conflict if no pooled host is free (the slot was taken since it was shown).
*/
private async selectRoundRobinHost(ctx: BookingContext, startUtc: Date): Promise<HostDocument> {
const { eventType } = ctx
const pool = await this.slots.resolvePoolHosts(eventType, ctx.host)
const endUtc = new Date(startUtc.getTime() + eventType.durationMinutes * 60_000)
const now = new Date()
// Determine which pooled hosts have this exact slot free (live re-check).
const checks = await Promise.all(
pool.map(async (host) => {
try {
const offered = await this.slots.availableSlots(host, eventType, startUtc, endUtc, now)
return offered.some((s) => s.startUtc.getTime() === startUtc.getTime()) ? host : null
} catch {
// A host whose calendar is unreachable is treated as not-free here so we
// never assign a booking we couldn't verify against that host's calendar.
return null
}
}),
)
const free = checks.filter((h): h is HostDocument => h !== null)
if (!free.length) throw new ConflictException('That time is no longer available.')
// Least-recently-booked: rank free hosts by their most recent booking time
// (a host with no bookings sorts first). Preserve pool order on ties.
const lastBookedAt = new Map<string, number>()
const recent = await this.bookingModel
.find({
hostId: { $in: free.map((h) => h._id) },
status: { $in: ['confirmed', 'pending'] },
})
.select('hostId createdAt')
.sort({ createdAt: -1 })
.exec()
for (const b of recent) {
const key = b.hostId.toString()
// createdAt is provided by the schema's timestamps:true.
const ts = (b as unknown as { createdAt?: Date }).createdAt?.getTime() ?? 0
if (!lastBookedAt.has(key)) lastBookedAt.set(key, ts)
}
const ranked = free
.map((host, index) => ({ host, index, last: lastBookedAt.get(host._id.toString()) ?? 0 }))
.sort((a, b) => a.last - b.last || a.index - b.index)
return ranked[0]!.host
}
/**
* Drives the Stalwart calendar write for a pending booking and, on success,
* promotes it to 'confirmed' and fires the branded confirmation email.
@@ -1,4 +1,6 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsHexColor,
@@ -12,10 +14,11 @@ import {
MaxLength,
Min,
} from 'class-validator'
import type { LocationType } from '../../../schemas/event-type.schema.js'
import type { EventTypeAssignment, LocationType } from '../../../schemas/event-type.schema.js'
const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
const LOCATIONS: LocationType[] = ['jitsi', 'phone', 'in_person', 'custom']
const ASSIGNMENTS: EventTypeAssignment[] = ['single', 'round_robin']
export class CreateEventTypeDto {
@IsString()
@@ -85,6 +88,18 @@ export class CreateEventTypeDto {
@IsOptional()
@IsBoolean()
ignoreAllDayEvents?: boolean
@IsOptional()
@IsEnum(ASSIGNMENTS)
assignment?: EventTypeAssignment
// Round-robin pool of host ids. Validated as mongo ids; capped to keep the
// union free/busy fan-out bounded.
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsMongoId({ each: true })
hostPool?: string[]
}
// All fields optional for PATCH. (Avoids a mapped-types dependency.)
@@ -102,4 +117,6 @@ export class UpdateEventTypeDto {
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string
@IsOptional() @IsHexColor() color?: string
@IsOptional() @IsBoolean() ignoreAllDayEvents?: boolean
@IsOptional() @IsEnum(ASSIGNMENTS) assignment?: EventTypeAssignment
@IsOptional() @IsArray() @ArrayMaxSize(50) @IsMongoId({ each: true }) hostPool?: string[]
}
@@ -1,7 +1,7 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { EventType, EventTypeDocument, LocationType } from '../../schemas/event-type.schema.js'
import { EventType, EventTypeAssignment, EventTypeDocument, LocationType } from '../../schemas/event-type.schema.js'
export interface EventTypeInput {
slug: string
@@ -18,6 +18,8 @@ export interface EventTypeInput {
locationDetails?: string
color?: string
ignoreAllDayEvents?: boolean
assignment?: EventTypeAssignment
hostPool?: string[]
}
@Injectable()
@@ -35,6 +37,7 @@ export class EventTypesService {
hostId,
...input,
availabilityScheduleId: new Types.ObjectId(input.availabilityScheduleId),
...(input.hostPool ? { hostPool: input.hostPool.map((id) => new Types.ObjectId(id)) } : {}),
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('An event type with that slug already exists for this host.')
@@ -62,6 +65,7 @@ export class EventTypesService {
availabilityScheduleId: input.availabilityScheduleId
? new Types.ObjectId(input.availabilityScheduleId)
: doc.availabilityScheduleId,
...(input.hostPool ? { hostPool: input.hostPool.map((hid) => new Types.ObjectId(hid)) } : {}),
})
return doc.save()
}
@@ -51,7 +51,14 @@ export class PublicSchedulingController {
@Query() q: SlotsQueryDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const slots = await this.slots.availableSlots(ctx.host, ctx.eventType, new Date(q.from), new Date(q.to))
const from = new Date(q.from)
const to = new Date(q.to)
// Round-robin: offer the union of the whole pool's free time (a slot shows if
// ANY pooled host is free). The single-host path is unchanged.
const slots =
ctx.eventType.assignment === 'round_robin'
? await this.slots.unionAvailableSlots(await this.slots.resolvePoolHosts(ctx.eventType, ctx.host), ctx.eventType, from, to)
: await this.slots.availableSlots(ctx.host, ctx.eventType, from, to)
return {
timezone: q.timezone,
durationMinutes: ctx.eventType.durationMinutes,
@@ -4,7 +4,7 @@ import { Model, Types } from 'mongoose'
import { AvailabilitySchedule, AvailabilityScheduleDocument } from '../../schemas/availability-schedule.schema.js'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventTypeDocument } from '../../schemas/event-type.schema.js'
import { HostDocument } from '../../schemas/scheduling-host.schema.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
@@ -16,10 +16,55 @@ export class SlotService {
@InjectModel(AvailabilitySchedule.name) private readonly scheduleModel: Model<AvailabilityScheduleDocument>,
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
) {}
// ── Round-robin (team) availability ─────────────────────────────────────────
// Resolves the effective pool for a round-robin event type: the configured
// hostPool restricted to ACTIVE hosts in the event type's tenant, with the
// owning host always included. Single-host event types never call this.
async resolvePoolHosts(eventType: EventTypeDocument, owner: HostDocument): Promise<HostDocument[]> {
const poolIds = (eventType.hostPool ?? []).map((id) => id.toString())
// Always fold in the event type's owning host and the caller-supplied owner
// (these can differ on the reschedule path, where `owner` is the host the
// existing booking was assigned to). Dedup by id.
const ids = new Set<string>([eventType.hostId.toString(), owner._id.toString(), ...poolIds])
const hosts = await this.hostModel
.find({ _id: { $in: [...ids].map((id) => new Types.ObjectId(id)) }, tenantId: eventType.tenantId, isActive: true })
.exec()
// Owner may have been deactivated; if nothing active remains, fall back to the
// owner so the page degrades to single-host rather than 500-ing.
return hosts.length ? hosts : [owner]
}
// Union of every pooled host's free slots: a start time is offered if it is free
// for AT LEAST ONE pooled host. Per-host calendar errors are tolerated (that host
// simply contributes no slots) UNLESS every host fails, in which case we fail
// closed (503) exactly like the single-host path — we never show a slot we cannot
// verify for anyone. The chosen host is decided later, at booking time.
async unionAvailableSlots(
hosts: HostDocument[],
eventType: EventTypeDocument,
fromUtc: Date,
toUtc: Date,
now: Date = new Date(),
): Promise<ComputedSlot[]> {
const results = await Promise.allSettled(
hosts.map((h) => this.availableSlots(h, eventType, fromUtc, toUtc, now)),
)
const fulfilled = results.filter((r): r is PromiseFulfilledResult<ComputedSlot[]> => r.status === 'fulfilled')
if (!fulfilled.length) {
throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.')
}
const byStart = new Map<number, ComputedSlot>()
for (const r of fulfilled) {
for (const slot of r.value) byStart.set(slot.startUtc.getTime(), slot)
}
return [...byStart.values()].sort((a, b) => a.startUtc.getTime() - b.startUtc.getTime())
}
// Free UTC slots for a host+event-type within [fromUtc, toUtc). Fails CLOSED on
// calendar errors (§9): rather than show slots we can't verify against live
// free/busy — risking a double-book — we surface 503 so the UI shows a retry.
@@ -5,6 +5,13 @@ export type EventTypeDocument = HydratedDocument<EventType>
export type LocationType = 'jitsi' | 'phone' | 'in_person' | 'custom'
// How a booking on this event type picks the host whose calendar the appointment
// lands on. 'single' (default) keeps the classic one-host-per-event-type behavior
// unchanged. 'round_robin' distributes across a pool of hosts: slots are the union
// of every pooled host's free time, and at booking time a free pooled host is
// chosen (least-recently-booked) and the event is written to THAT host's calendar.
export type EventTypeAssignment = 'single' | 'round_robin'
// A bookable appointment type for a host (e.g. "30-min consultation"). Carries
// the booking rules slot computation needs: duration, granularity, buffers,
// notice window, and horizon.
@@ -67,6 +74,17 @@ export class EventType {
@Prop({ default: true, index: true })
isActive!: boolean
// Host-assignment strategy. Defaults to 'single' so existing event types keep
// their original single-host behavior with no migration needed.
@Prop({ enum: ['single', 'round_robin'], default: 'single' })
assignment!: EventTypeAssignment
// Round-robin host pool. Ignored unless assignment === 'round_robin'. The owning
// host (hostId) is always treated as part of the effective pool so a misconfigured
// empty pool degrades gracefully to single-host behavior on the owner.
@Prop({ type: [Types.ObjectId], ref: 'Host', default: [] })
hostPool!: Types.ObjectId[]
}
export const EventTypeSchema = SchemaFactory.createForClass(EventType)