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, @InjectModel(Booking.name) private readonly bookingModel: Model, @InjectModel(SlotLock.name) private readonly lockModel: Model, @InjectModel(Host.name) private readonly hostModel: Model, 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 { 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([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 { const results = await Promise.allSettled( hosts.map((h) => this.availableSlots(h, eventType, fromUtc, toUtc, now)), ) const fulfilled = results.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') if (!fulfilled.length) { throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.') } const byStart = new Map() 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 { 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 })) } }