feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars
First-party booking system on top of Stalwart calendars (no third-party scheduling dependency). Hosts expose public booking pages; visitors pick a slot computed from the host's live Stalwart free/busy, and confirming writes the event to the host's calendar and sends a dezky-branded confirmation with an .ics. platform-api (services/platform-api/src/scheduling): - Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule, EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL). - StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability, event create/delete, scheduleAgent=client) + on-behalf app-password provisioning. CredentialCipher for at-rest encryption. - DST-correct slot engine (Luxon) with unit tests; two-layer double-booking guard (atomic SlotLock + live free/busy re-check). - Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission, self-service manage tokens. /api/v1 public + tenant-gated admin routes, per-IP rate limiting. apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) — path-based tenant resolution, per-tenant brand colour, booking + manage flows. apps/portal: admin scheduling page (hosts, event types, availability, bookings with edit/delete + admin cancel/reschedule) and proxy routes. infra: booking dev service in docker-compose; scheduling env vars.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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 { 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>,
|
||||
private readonly provisioner: CredentialProvisioner,
|
||||
private readonly gateway: JmapCalendarGateway,
|
||||
) {}
|
||||
|
||||
// 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)
|
||||
} 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 }))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user