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