feat(scheduling): round-robin team event types
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user