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