135 lines
6.3 KiB
TypeScript
135 lines
6.3 KiB
TypeScript
import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common'
|
|
import { InjectModel } from '@nestjs/mongoose'
|
|
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 { 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'
|
|
import { computeSlots, ComputedSlot } from './slot-computer.js'
|
|
|
|
@Injectable()
|
|
export class SlotService {
|
|
constructor(
|
|
@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.
|
|
async availableSlots(
|
|
host: HostDocument,
|
|
eventType: EventTypeDocument,
|
|
fromUtc: Date,
|
|
toUtc: Date,
|
|
now: Date = new Date(),
|
|
): Promise<ComputedSlot[]> {
|
|
const schedule = await this.scheduleModel.findById(eventType.availabilityScheduleId).exec()
|
|
if (!schedule) throw new NotFoundException('Event type has no availability schedule')
|
|
|
|
const access = await this.provisioner.resolveAccess(host)
|
|
|
|
let calendarBusy
|
|
try {
|
|
calendarBusy = await this.gateway.getBusyIntervals(access, fromUtc, toUtc, {
|
|
ignoreAllDayEvents: eventType.ignoreAllDayEvents,
|
|
})
|
|
} catch {
|
|
throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.')
|
|
}
|
|
|
|
const [bookingBusy, lockBusy] = await Promise.all([
|
|
this.confirmedBookingIntervals(host._id, fromUtc, toUtc),
|
|
this.activeLockIntervals(host._id, fromUtc, toUtc, now),
|
|
])
|
|
|
|
return computeSlots({
|
|
durationMinutes: eventType.durationMinutes,
|
|
slotIntervalMinutes: eventType.slotIntervalMinutes,
|
|
bufferBeforeMinutes: eventType.bufferBeforeMinutes,
|
|
bufferAfterMinutes: eventType.bufferAfterMinutes,
|
|
minimumNoticeMinutes: eventType.minimumNoticeMinutes,
|
|
maximumDaysInFuture: eventType.maximumDaysInFuture,
|
|
scheduleTimezone: schedule.timezone,
|
|
weeklyRules: schedule.weeklyRules,
|
|
dateOverrides: schedule.dateOverrides,
|
|
busy: [...calendarBusy, ...bookingBusy, ...lockBusy],
|
|
now,
|
|
fromUtc,
|
|
toUtc,
|
|
})
|
|
}
|
|
|
|
private async confirmedBookingIntervals(hostId: Types.ObjectId, from: Date, to: Date) {
|
|
const bookings = await this.bookingModel
|
|
.find({ hostId, status: { $in: ['confirmed', 'pending'] }, startUtc: { $lt: to }, endUtc: { $gt: from } })
|
|
.select('startUtc endUtc')
|
|
.exec()
|
|
return bookings.map((b) => ({ startUtc: b.startUtc, endUtc: b.endUtc }))
|
|
}
|
|
|
|
private async activeLockIntervals(hostId: Types.ObjectId, from: Date, to: Date, now: Date) {
|
|
const locks = await this.lockModel
|
|
.find({
|
|
hostId,
|
|
startUtc: { $lt: to },
|
|
endUtc: { $gt: from },
|
|
$or: [{ expiresAt: null }, { expiresAt: { $gt: now } }],
|
|
})
|
|
.select('startUtc endUtc')
|
|
.exec()
|
|
return locks.map((l) => ({ startUtc: l.startUtc, endUtc: l.endUtc }))
|
|
}
|
|
}
|