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
@@ -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.