feat(scheduling): round-robin team event types
This commit is contained in:
@@ -26,6 +26,8 @@ interface EventType {
|
|||||||
availabilityScheduleId: string
|
availabilityScheduleId: string
|
||||||
locationType: string
|
locationType: string
|
||||||
ignoreAllDayEvents: boolean
|
ignoreAllDayEvents: boolean
|
||||||
|
assignment?: 'single' | 'round_robin'
|
||||||
|
hostPool?: string[]
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
interface MinuteInterval { startMinute: number; endMinute: number }
|
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 selectedHostId = ref<string | null>(null)
|
||||||
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? 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 detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
|
||||||
|
|
||||||
const eventTypes = ref<EventType[]>([])
|
const eventTypes = ref<EventType[]>([])
|
||||||
@@ -289,6 +294,8 @@ const etForm = reactive({
|
|||||||
availabilityScheduleId: '',
|
availabilityScheduleId: '',
|
||||||
locationType: 'jitsi',
|
locationType: 'jitsi',
|
||||||
ignoreAllDayEvents: true,
|
ignoreAllDayEvents: true,
|
||||||
|
assignment: 'single' as 'single' | 'round_robin',
|
||||||
|
hostPool: [] as string[],
|
||||||
})
|
})
|
||||||
const etEditingId = ref<string | null>(null)
|
const etEditingId = ref<string | null>(null)
|
||||||
const etSlugTouched = ref(false)
|
const etSlugTouched = ref(false)
|
||||||
@@ -303,6 +310,7 @@ function openEt(et?: EventType) {
|
|||||||
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
|
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
|
||||||
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
|
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
|
||||||
locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
|
locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
|
||||||
|
assignment: et.assignment ?? 'single', hostPool: [...(et.hostPool ?? [])],
|
||||||
})
|
})
|
||||||
etSlugTouched.value = true // don't auto-rewrite an existing slug
|
etSlugTouched.value = true // don't auto-rewrite an existing slug
|
||||||
} else {
|
} else {
|
||||||
@@ -311,7 +319,7 @@ function openEt(et?: EventType) {
|
|||||||
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
|
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
|
||||||
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
|
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
|
||||||
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
|
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
|
||||||
ignoreAllDayEvents: true,
|
ignoreAllDayEvents: true, assignment: 'single', hostPool: [],
|
||||||
})
|
})
|
||||||
etSlugTouched.value = false
|
etSlugTouched.value = false
|
||||||
}
|
}
|
||||||
@@ -325,13 +333,18 @@ async function submitEt() {
|
|||||||
if (!selectedHostId.value || !etValid.value) return
|
if (!selectedHostId.value || !etValid.value) return
|
||||||
etBusy.value = true
|
etBusy.value = true
|
||||||
try {
|
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) {
|
if (etEditingId.value) {
|
||||||
// slug is immutable after creation (public links would break) → omit it.
|
// 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 })
|
await request(`${base.value}/event-types/${etEditingId.value}`, { method: 'PATCH', body: patch })
|
||||||
toast.ok('Event type updated')
|
toast.ok('Event type updated')
|
||||||
} else {
|
} 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')
|
toast.ok('Event type created')
|
||||||
}
|
}
|
||||||
etOpen.value = false
|
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>
|
<p v-if="!availability.length" class="hint">Create an availability schedule first.</p>
|
||||||
<Card v-for="et in eventTypes" :key="et._id" class="item">
|
<Card v-for="et in eventTypes" :key="et._id" class="item">
|
||||||
<div class="itemmain">
|
<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>
|
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="itemactions">
|
<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" />
|
<input type="checkbox" v-model="etForm.ignoreAllDayEvents" />
|
||||||
Ignore all-day events when checking availability
|
Ignore all-day events when checking availability
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
|
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ export class BookingsService {
|
|||||||
input: ConfirmBookingInput,
|
input: ConfirmBookingInput,
|
||||||
opts: { rescheduledFromBookingId?: Types.ObjectId },
|
opts: { rescheduledFromBookingId?: Types.ObjectId },
|
||||||
): Promise<BookingDocument> {
|
): 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
|
const { host, eventType, tenant } = ctx
|
||||||
if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')
|
if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')
|
||||||
|
|
||||||
@@ -203,6 +212,62 @@ export class BookingsService {
|
|||||||
return booking
|
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,
|
* Drives the Stalwart calendar write for a pending booking and, on success,
|
||||||
* promotes it to 'confirmed' and fires the branded confirmation email.
|
* promotes it to 'confirmed' and fires the branded confirmation email.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsHexColor,
|
IsHexColor,
|
||||||
@@ -12,10 +14,11 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator'
|
} 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 SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
|
||||||
const LOCATIONS: LocationType[] = ['jitsi', 'phone', 'in_person', 'custom']
|
const LOCATIONS: LocationType[] = ['jitsi', 'phone', 'in_person', 'custom']
|
||||||
|
const ASSIGNMENTS: EventTypeAssignment[] = ['single', 'round_robin']
|
||||||
|
|
||||||
export class CreateEventTypeDto {
|
export class CreateEventTypeDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -85,6 +88,18 @@ export class CreateEventTypeDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
ignoreAllDayEvents?: boolean
|
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.)
|
// All fields optional for PATCH. (Avoids a mapped-types dependency.)
|
||||||
@@ -102,4 +117,6 @@ export class UpdateEventTypeDto {
|
|||||||
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string
|
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string
|
||||||
@IsOptional() @IsHexColor() color?: string
|
@IsOptional() @IsHexColor() color?: string
|
||||||
@IsOptional() @IsBoolean() ignoreAllDayEvents?: boolean
|
@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 { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model, Types } from '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 {
|
export interface EventTypeInput {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -18,6 +18,8 @@ export interface EventTypeInput {
|
|||||||
locationDetails?: string
|
locationDetails?: string
|
||||||
color?: string
|
color?: string
|
||||||
ignoreAllDayEvents?: boolean
|
ignoreAllDayEvents?: boolean
|
||||||
|
assignment?: EventTypeAssignment
|
||||||
|
hostPool?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -35,6 +37,7 @@ export class EventTypesService {
|
|||||||
hostId,
|
hostId,
|
||||||
...input,
|
...input,
|
||||||
availabilityScheduleId: new Types.ObjectId(input.availabilityScheduleId),
|
availabilityScheduleId: new Types.ObjectId(input.availabilityScheduleId),
|
||||||
|
...(input.hostPool ? { hostPool: input.hostPool.map((id) => new Types.ObjectId(id)) } : {}),
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 11000) throw new ConflictException('An event type with that slug already exists for this host.')
|
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
|
availabilityScheduleId: input.availabilityScheduleId
|
||||||
? new Types.ObjectId(input.availabilityScheduleId)
|
? new Types.ObjectId(input.availabilityScheduleId)
|
||||||
: doc.availabilityScheduleId,
|
: doc.availabilityScheduleId,
|
||||||
|
...(input.hostPool ? { hostPool: input.hostPool.map((hid) => new Types.ObjectId(hid)) } : {}),
|
||||||
})
|
})
|
||||||
return doc.save()
|
return doc.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ export class PublicSchedulingController {
|
|||||||
@Query() q: SlotsQueryDto,
|
@Query() q: SlotsQueryDto,
|
||||||
) {
|
) {
|
||||||
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
|
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 {
|
return {
|
||||||
timezone: q.timezone,
|
timezone: q.timezone,
|
||||||
durationMinutes: ctx.eventType.durationMinutes,
|
durationMinutes: ctx.eventType.durationMinutes,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Model, Types } from 'mongoose'
|
|||||||
import { AvailabilitySchedule, AvailabilityScheduleDocument } from '../../schemas/availability-schedule.schema.js'
|
import { AvailabilitySchedule, AvailabilityScheduleDocument } from '../../schemas/availability-schedule.schema.js'
|
||||||
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
|
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
|
||||||
import { EventTypeDocument } from '../../schemas/event-type.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 { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
|
||||||
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
|
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
|
||||||
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.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(AvailabilitySchedule.name) private readonly scheduleModel: Model<AvailabilityScheduleDocument>,
|
||||||
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
|
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
|
||||||
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
|
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
|
||||||
|
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
|
||||||
private readonly provisioner: CredentialProvisioner,
|
private readonly provisioner: CredentialProvisioner,
|
||||||
private readonly gateway: JmapCalendarGateway,
|
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
|
// 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
|
// 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.
|
// 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'
|
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
|
// A bookable appointment type for a host (e.g. "30-min consultation"). Carries
|
||||||
// the booking rules slot computation needs: duration, granularity, buffers,
|
// the booking rules slot computation needs: duration, granularity, buffers,
|
||||||
// notice window, and horizon.
|
// notice window, and horizon.
|
||||||
@@ -67,6 +74,17 @@ export class EventType {
|
|||||||
|
|
||||||
@Prop({ default: true, index: true })
|
@Prop({ default: true, index: true })
|
||||||
isActive!: boolean
|
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)
|
export const EventTypeSchema = SchemaFactory.createForClass(EventType)
|
||||||
|
|||||||
Reference in New Issue
Block a user