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:
Ronni Baslund
2026-06-07 00:17:36 +02:00
parent aee8f13899
commit 5ed3d2bc5f
62 changed files with 13633 additions and 1 deletions
@@ -0,0 +1,68 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import {
AvailabilitySchedule,
AvailabilityScheduleDocument,
DateOverride,
WeeklyRule,
} from '../../schemas/availability-schedule.schema.js'
import { EventType, EventTypeDocument } from '../../schemas/event-type.schema.js'
export interface AvailabilityInput {
name: string
timezone: string
weeklyRules: WeeklyRule[]
dateOverrides?: DateOverride[]
}
@Injectable()
export class AvailabilityService {
constructor(
@InjectModel(AvailabilitySchedule.name)
private readonly model: Model<AvailabilityScheduleDocument>,
@InjectModel(EventType.name)
private readonly eventTypeModel: Model<EventTypeDocument>,
) {}
list(tenantId: Types.ObjectId, hostId: Types.ObjectId): Promise<AvailabilityScheduleDocument[]> {
return this.model.find({ tenantId, hostId }).sort({ name: 1 }).exec()
}
create(tenantId: Types.ObjectId, hostId: Types.ObjectId, input: AvailabilityInput): Promise<AvailabilityScheduleDocument> {
return this.model.create({
tenantId,
hostId,
name: input.name,
timezone: input.timezone,
weeklyRules: input.weeklyRules,
dateOverrides: input.dateOverrides ?? [],
})
}
async get(tenantId: Types.ObjectId, id: string): Promise<AvailabilityScheduleDocument> {
const doc = await this.model.findOne({ _id: id, tenantId }).exec()
if (!doc) throw new NotFoundException('Availability schedule not found')
return doc
}
async update(tenantId: Types.ObjectId, id: string, input: Partial<AvailabilityInput>): Promise<AvailabilityScheduleDocument> {
const doc = await this.get(tenantId, id)
if (input.name !== undefined) doc.name = input.name
if (input.timezone !== undefined) doc.timezone = input.timezone
if (input.weeklyRules !== undefined) doc.weeklyRules = input.weeklyRules
if (input.dateOverrides !== undefined) doc.dateOverrides = input.dateOverrides
return doc.save()
}
async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
// Don't orphan event types: a deleted schedule would break their slot
// computation. Require the caller to reassign/delete those first.
const inUse = await this.eventTypeModel.countDocuments({ tenantId, availabilityScheduleId: id }).exec()
if (inUse > 0) {
throw new ConflictException(`This schedule is used by ${inUse} event type(s). Reassign or delete them first.`)
}
const res = await this.model.deleteOne({ _id: id, tenantId }).exec()
if (res.deletedCount === 0) throw new NotFoundException('Availability schedule not found')
}
}
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator'
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class MinuteIntervalDto {
@IsInt() @Min(0) @Max(1440) startMinute!: number
@IsInt() @Min(0) @Max(1440) endMinute!: number
}
export class WeeklyRuleDto {
@IsInt() @Min(0) @Max(6) dayOfWeek!: number
@IsArray()
@ValidateNested({ each: true })
@Type(() => MinuteIntervalDto)
intervals!: MinuteIntervalDto[]
}
export class DateOverrideDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date!: string
@IsBoolean()
isUnavailable!: boolean
@IsArray()
@ValidateNested({ each: true })
@Type(() => MinuteIntervalDto)
intervals!: MinuteIntervalDto[]
}
export class CreateAvailabilityDto {
@IsString()
@MaxLength(120)
name!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
@IsArray()
@ValidateNested({ each: true })
@Type(() => WeeklyRuleDto)
weeklyRules!: WeeklyRuleDto[]
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateOverrideDto)
dateOverrides?: DateOverrideDto[]
}
export class UpdateAvailabilityDto {
@IsOptional() @IsString() @MaxLength(120) name?: string
@IsOptional()
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone?: string
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WeeklyRuleDto)
weeklyRules?: WeeklyRuleDto[]
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateOverrideDto)
dateOverrides?: DateOverrideDto[]
}
@@ -0,0 +1,342 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes, randomUUID } from 'node:crypto'
import { Model, Types } from 'mongoose'
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 { confirmationEmail, cancellationEmail } from '../email/booking-templates.js'
import { buildBookingIcs } from '../email/ics.js'
import { JmapMailer } from '../email/jmap-mailer.service.js'
import { SlotService } from '../slots/slot.service.js'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
const HOLD_MS = 10 * 60 * 1000
// Tenant identity needed for branding the calendar event + email.
export interface BookingTenantRef {
_id: Types.ObjectId
slug: string
name: string
brandColor?: string
}
export interface BookingContext {
tenant: BookingTenantRef
host: HostDocument
eventType: EventTypeDocument
}
export interface ConfirmBookingInput {
startUtc: Date
attendeeName: string
attendeeEmail: string
attendeeTimezone: string
attendeeNotes?: string
holdId?: string
}
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name)
private readonly bookingPublicUrl: string
private readonly meetBaseUrl: string
constructor(
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
private readonly slots: SlotService,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
private readonly mailer: JmapMailer,
config: ConfigService,
) {
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
this.meetBaseUrl = (config.get<string>('MEET_PUBLIC_URL') ?? 'https://meet.dezky.local').replace(/\/$/, '')
}
// ── Holds (optional reservation during checkout) ───────────────────────────
async hold(ctx: BookingContext, startUtc: Date): Promise<{ holdId: string; expiresAt: Date }> {
const endUtc = new Date(startUtc.getTime() + ctx.eventType.durationMinutes * 60_000)
const holdId = randomBytes(18).toString('hex')
const expiresAt = new Date(Date.now() + HOLD_MS)
try {
await this.lockModel.create({
tenantId: ctx.tenant._id,
hostId: ctx.host._id,
startUtc,
endUtc,
expiresAt,
holdToken: holdId,
bookingId: null,
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('That time is currently being booked by someone else.')
throw err
}
return { holdId, expiresAt }
}
// ── Confirm ────────────────────────────────────────────────────────────────
async confirm(ctx: BookingContext, input: ConfirmBookingInput): Promise<BookingDocument> {
return this.createConfirmedBooking(ctx, input, {})
}
private async createConfirmedBooking(
ctx: BookingContext,
input: ConfirmBookingInput,
opts: { rescheduledFromBookingId?: Types.ObjectId },
): Promise<BookingDocument> {
const { host, eventType, tenant } = ctx
if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')
const now = new Date()
const startUtc = input.startUtc
const endUtc = new Date(startUtc.getTime() + eventType.durationMinutes * 60_000)
// (a) Validate the time is genuinely offered AND free against live free/busy
// (this performs the §8.2 live re-check via the calendar gateway).
const offered = await this.slots.availableSlots(host, eventType, startUtc, endUtc, now)
if (!offered.some((s) => s.startUtc.getTime() === startUtc.getTime())) {
throw new ConflictException('That time is no longer available.')
}
// (b) Persist a pending booking so we have an id to attach to the lock.
const calendarEventUid = randomUUID()
const manageToken = randomBytes(24).toString('hex')
const location = this.resolveLocation(ctx)
const booking = await this.bookingModel.create({
tenantId: tenant._id,
eventTypeId: eventType._id,
hostId: host._id,
status: 'pending',
startUtc,
endUtc,
attendeeName: input.attendeeName,
attendeeEmail: input.attendeeEmail,
attendeeTimezone: input.attendeeTimezone,
attendeeNotes: input.attendeeNotes,
calendarEventUid,
manageToken,
locationType: location.type,
locationUrl: location.url,
rescheduledFromBookingId: opts.rescheduledFromBookingId ?? null,
reminderState: 'none',
})
// (c) §8.2 layer 1 — atomic slot claim. Claim our own hold by token if given;
// otherwise insert a fresh unique lock (dup-key => slot already taken).
let claimed = false
if (input.holdId) {
const upd = await this.lockModel
.findOneAndUpdate(
{ hostId: host._id, startUtc, holdToken: input.holdId, bookingId: null },
{ $set: { bookingId: booking._id, expiresAt: null, endUtc } },
)
.exec()
claimed = !!upd
}
if (!claimed) {
try {
await this.lockModel.create({
tenantId: tenant._id,
hostId: host._id,
startUtc,
endUtc,
expiresAt: null,
bookingId: booking._id,
holdToken: null,
})
} catch (err: any) {
await this.bookingModel.deleteOne({ _id: booking._id }).exec()
if (err?.code === 11000) throw new ConflictException('That time was just taken.')
throw err
}
}
// (d) Write to the host's Stalwart calendar; promote to confirmed on success.
let access: HostCalendarAccess
try {
access = await this.provisioner.resolveAccess(host)
const { id } = await this.gateway.createEvent(access, {
uid: calendarEventUid,
title: eventType.title,
description: input.attendeeNotes,
startUtc,
endUtc,
hostTimezone: host.timezone,
location: location.url,
hostEmail: host.email,
attendeeName: input.attendeeName,
attendeeEmail: input.attendeeEmail,
})
booking.calendarEventId = id
booking.status = 'confirmed'
await booking.save()
} catch (err) {
// Compensate: never leave a confirmed-looking booking with no calendar event.
await this.lockModel.deleteOne({ hostId: host._id, startUtc, bookingId: booking._id }).exec()
await this.bookingModel.deleteOne({ _id: booking._id }).exec()
this.logger.error(`Calendar write failed for ${host.email}: ${(err as Error).message}`)
throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
}
// (e) Branded confirmation email — best-effort (booking already valid).
this.sendEmail(ctx, booking, access, 'confirmation').catch((e) =>
this.logger.warn(`Confirmation email failed for ${booking.attendeeEmail}: ${e.message}`),
)
return booking
}
// ── Manage / cancel / reschedule ───────────────────────────────────────────
async getByManageToken(token: string): Promise<BookingDocument> {
const booking = await this.bookingModel.findOne({ manageToken: token }).exec()
if (!booking) throw new NotFoundException('Booking not found')
return booking
}
// Tenant-scoped lookup for the admin surface (cancel from the bookings list).
async getForTenant(tenantId: Types.ObjectId, id: string): Promise<BookingDocument> {
const booking = await this.bookingModel.findOne({ _id: id, tenantId }).exec()
if (!booking) throw new NotFoundException('Booking not found')
return booking
}
async cancel(token: string, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
return this.performCancel(await this.getByManageToken(token), reason, ctx)
}
// Admin cancel by booking doc (already tenant-checked by the caller).
async cancelResolved(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
return this.performCancel(booking, reason, ctx)
}
private async performCancel(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
if (booking.status === 'cancelled') return booking
const access = await this.provisioner.resolveAccess(ctx.host)
if (booking.calendarEventId) {
await this.gateway.deleteEvent(access, booking.calendarEventId).catch((e) =>
this.logger.warn(`Calendar delete failed for booking ${booking._id}: ${e.message}`),
)
}
booking.status = 'cancelled'
booking.cancelledAt = new Date()
booking.cancellationReason = reason
await booking.save()
await this.lockModel.deleteOne({ hostId: booking.hostId, startUtc: booking.startUtc, bookingId: booking._id }).exec()
this.sendEmail(ctx, booking, access, 'cancellation').catch((e) =>
this.logger.warn(`Cancellation email failed: ${e.message}`),
)
return booking
}
async reschedule(token: string, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
return this.performReschedule(await this.getByManageToken(token), newStartUtc, ctx)
}
// Admin reschedule by booking doc (already tenant-checked by the caller).
async rescheduleResolved(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
return this.performReschedule(old, newStartUtc, ctx)
}
private async performReschedule(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
if (old.status === 'cancelled') throw new BadRequestException('This booking was cancelled and cannot be rescheduled.')
// Create the replacement booking first (validates + claims the new slot).
const fresh = await this.createConfirmedBooking(
ctx,
{
startUtc: newStartUtc,
attendeeName: old.attendeeName,
attendeeEmail: old.attendeeEmail,
attendeeTimezone: old.attendeeTimezone,
attendeeNotes: old.attendeeNotes,
},
{ rescheduledFromBookingId: old._id },
)
// Tear down the old one (delete its calendar event, mark rescheduled).
const access = await this.provisioner.resolveAccess(ctx.host)
if (old.calendarEventId) {
await this.gateway.deleteEvent(access, old.calendarEventId).catch((e) =>
this.logger.warn(`Old event delete failed during reschedule: ${e.message}`),
)
}
old.status = 'rescheduled'
await old.save()
await this.lockModel.deleteOne({ hostId: old.hostId, startUtc: old.startUtc, bookingId: old._id }).exec()
return fresh
}
// ── Admin listing ──────────────────────────────────────────────────────────
listForTenant(tenantId: Types.ObjectId, hostId?: Types.ObjectId): Promise<BookingDocument[]> {
const filter: Record<string, unknown> = { tenantId }
if (hostId) filter.hostId = hostId
return this.bookingModel.find(filter).sort({ startUtc: -1 }).limit(500).exec()
}
// ── Helpers ────────────────────────────────────────────────────────────────
private resolveLocation(ctx: BookingContext): { type: BookingDocument['locationType']; url?: string } {
const et = ctx.eventType
if (et.locationType === 'jitsi') {
return { type: 'jitsi', url: `${this.meetBaseUrl}/${ctx.tenant.slug}-${randomBytes(6).toString('hex')}` }
}
return { type: et.locationType, url: et.locationDetails }
}
private async sendEmail(
ctx: BookingContext,
booking: BookingDocument,
access: HostCalendarAccess,
kind: 'confirmation' | 'cancellation',
): Promise<void> {
const emailCtx = {
brandName: ctx.tenant.name,
brandColor: ctx.tenant.brandColor,
eventTitle: ctx.eventType.title,
hostName: ctx.host.displayName,
attendeeName: booking.attendeeName,
startUtc: booking.startUtc,
endUtc: booking.endUtc,
attendeeTimezone: booking.attendeeTimezone,
location: booking.locationUrl,
manageUrl: `${this.bookingPublicUrl}/manage/${booking.manageToken}`,
}
const rendered = kind === 'confirmation' ? confirmationEmail(emailCtx) : cancellationEmail(emailCtx)
const ics = buildBookingIcs({
uid: booking.calendarEventUid,
start: booking.startUtc,
end: booking.endUtc,
summary: `${ctx.eventType.title} with ${ctx.host.displayName}`,
description: booking.attendeeNotes,
location: booking.locationUrl,
organizerName: ctx.host.displayName,
organizerEmail: ctx.host.email,
attendeeName: booking.attendeeName,
attendeeEmail: booking.attendeeEmail,
})
await this.mailer.send(access, {
to: booking.attendeeEmail,
toName: booking.attendeeName,
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
ics,
icsFilename: 'invite.ics',
})
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
// AES-256-GCM at-rest encryption for Stalwart host credentials (app passwords).
// The key comes from SCHEDULING_CREDENTIAL_KEY (64 hex chars = 32 bytes); in
// production this is sourced from KMS/sealed-secrets. We store ciphertext + iv +
// authTag separately (all base64) so the GCM auth tag is verified on every open —
// a tampered ciphertext throws rather than returning garbage. Secrets are NEVER
// logged: this module deals only in opaque buffers.
const ALGO = 'aes-256-gcm'
const IV_BYTES = 12 // GCM standard nonce length
export interface SealedSecret {
encryptedSecret: string // base64 ciphertext
iv: string // base64 nonce
authTag: string // base64 GCM tag
}
@Injectable()
export class CredentialCipher {
private readonly key: Buffer
constructor(config: ConfigService) {
const hex = config.get<string>('SCHEDULING_CREDENTIAL_KEY') ?? ''
const key = Buffer.from(hex, 'hex')
if (key.length !== 32) {
throw new Error(
'SCHEDULING_CREDENTIAL_KEY must be 32 bytes (64 hex chars). Generate with: openssl rand -hex 32',
)
}
this.key = key
}
seal(plaintext: string): SealedSecret {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALGO, this.key, iv)
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
return {
encryptedSecret: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
}
}
open(sealed: SealedSecret): string {
const decipher = createDecipheriv(ALGO, this.key, Buffer.from(sealed.iv, 'base64'))
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'))
const dec = Buffer.concat([
decipher.update(Buffer.from(sealed.encryptedSecret, 'base64')),
decipher.final(),
])
return dec.toString('utf8')
}
}
@@ -0,0 +1,98 @@
// Branded, dependency-free booking email templates (text + HTML). Per CLAUDE.md
// the brand surface is whitelabel: `brandName`/`brandColor` come from the tenant,
// not fixed dezky styling. End-user copy may be localized later; English for now.
export interface BookingEmailContext {
brandName: string
brandColor?: string
eventTitle: string
hostName: string
attendeeName: string
startUtc: Date
endUtc: Date
attendeeTimezone: string
location?: string
manageUrl: string
}
export interface RenderedEmail {
subject: string
text: string
html: string
}
function fmtRange(start: Date, end: Date, tz: string): string {
const date = new Intl.DateTimeFormat('en-GB', {
timeZone: tz, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).format(start)
const t = (d: Date) =>
new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(d)
return `${date}, ${t(start)}${t(end)} (${tz})`
}
function shell(accent: string, brandName: string, heading: string, bodyHtml: string): string {
return `<!doctype html><html><body style="margin:0;background:#f6f6f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a">
<div style="max-width:520px;margin:0 auto;padding:32px 16px">
<div style="background:#fff;border:1px solid #ececec;border-radius:14px;overflow:hidden">
<div style="height:6px;background:${accent}"></div>
<div style="padding:28px">
<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#888">${escapeHtml(brandName)}</div>
<h1 style="font-size:20px;margin:8px 0 16px">${escapeHtml(heading)}</h1>
${bodyHtml}
</div>
</div>
<p style="text-align:center;color:#aaa;font-size:12px;margin-top:16px">Powered by ${escapeHtml(brandName)}</p>
</div></body></html>`
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
}
export function confirmationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Confirmed: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking is confirmed.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
ctx.location ? `Location: ${ctx.location}` : '',
``,
`A calendar invite is attached.`,
`Need to change it? ${ctx.manageUrl}`,
].filter(Boolean).join('\n')
const html = shell(accent, ctx.brandName, 'Your booking is confirmed', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444">${escapeHtml(when)}</p>
${ctx.location ? `<p style="margin:0 0 4px;color:#444">${escapeHtml(ctx.location)}</p>` : ''}
<p style="margin:18px 0 0;font-size:14px;color:#666">A calendar invite (.ics) is attached.</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Reschedule or cancel</a></p>
`)
return { subject, text, html }
}
export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Cancelled: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking has been cancelled.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
``,
`You can book a new time here: ${ctx.manageUrl}`,
].join('\n')
const html = shell(accent, ctx.brandName, 'Your booking was cancelled', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444;text-decoration:line-through">${escapeHtml(when)}</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Book a new time</a></p>
`)
return { subject, text, html }
}
@@ -0,0 +1,36 @@
import ical, { ICalCalendarMethod } from 'ical-generator'
// Build a PUBLISH .ics for the attendee's "add to calendar". METHOD:PUBLISH (not
// REQUEST) because dezky is not acting as the iMIP organizer — we send a branded
// confirmation with an attachable event, not a scheduling invitation. The UID
// matches the booking's calendarEventUid so a re-send updates rather than dupes.
export interface BookingIcsInput {
uid: string
start: Date
end: Date
summary: string
description?: string
location?: string
organizerName: string
organizerEmail: string
attendeeName: string
attendeeEmail: string
}
export function buildBookingIcs(p: BookingIcsInput): string {
const cal = ical({
prodId: { company: 'dezky', product: 'scheduling', language: 'EN' },
method: ICalCalendarMethod.PUBLISH,
})
cal.createEvent({
id: p.uid,
start: p.start,
end: p.end,
summary: p.summary,
description: p.description,
location: p.location,
organizer: { name: p.organizerName, email: p.organizerEmail },
attendees: [{ name: p.attendeeName, email: p.attendeeEmail, rsvp: false }],
})
return cal.toString()
}
@@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
// Sends dezky-branded booking emails via JMAP through the host's own mailbox
// (From = host address). Uses the same app-password access as the calendar
// gateway — no separate SMTP surface. Flow: upload the .ics blob → Email/set a
// draft with text+html bodies and the .ics attachment → EmailSubmission/set to
// send. Never logs message bodies or credentials.
const CORE = 'urn:ietf:params:jmap:core'
const MAIL = 'urn:ietf:params:jmap:mail'
const SUBMISSION = 'urn:ietf:params:jmap:submission'
interface Session {
apiUrl: string
uploadUrl: string
primaryAccounts: Record<string, string>
}
type MethodResponse = [string, Record<string, any>, string]
export interface OutboundEmail {
to: string
toName: string
subject: string
text: string
html: string
ics: string
icsFilename: string
}
@Injectable()
export class JmapMailer {
private readonly logger = new Logger(JmapMailer.name)
async send(access: HostCalendarAccess, msg: OutboundEmail): Promise<void> {
const auth = `Basic ${Buffer.from(`${access.email}:${access.secret}`).toString('base64')}`
const origin = new URL(access.jmapSessionUrl).origin
const session = (await (await fetch(access.jmapSessionUrl, { headers: { Authorization: auth } })).json()) as Session
const accountId = session.primaryAccounts[MAIL]
if (!accountId) throw new Error('Host mailbox has no mail capability')
const apiUrl = origin + new URL(session.apiUrl, origin).pathname
// 1. Upload the .ics as a blob (internal hostname).
const uploadPath = new URL(session.uploadUrl.replace('{accountId}', accountId), origin).pathname
const upRes = await fetch(origin + uploadPath, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'text/calendar' },
body: msg.ics,
})
if (!upRes.ok) throw new Error(`blob upload ${upRes.status}`)
const blobId = ((await upRes.json()) as { blobId: string }).blobId
// 2. Resolve a filing mailbox (prefer Sent, else Drafts) and the submission
// identity (required by EmailSubmission/set) for the host address.
const meta = await this.call(apiUrl, auth, [CORE, MAIL, SUBMISSION], [
['Mailbox/get', { accountId, properties: ['role'] }, 'm'],
['Identity/get', { accountId, properties: ['email'] }, 'i'],
])
const mailboxes = (meta.find((r) => r[0] === 'Mailbox/get')?.[1]?.list ?? []) as Array<{ id: string; role?: string }>
const fileMailbox =
mailboxes.find((m) => m.role === 'sent')?.id ?? mailboxes.find((m) => m.role === 'drafts')?.id ?? mailboxes[0]?.id
if (!fileMailbox) throw new Error('Host mailbox has no filing folder')
const identities = (meta.find((r) => r[0] === 'Identity/get')?.[1]?.list ?? []) as Array<{ id: string; email?: string }>
const identityId =
identities.find((i) => i.email?.toLowerCase() === access.email.toLowerCase())?.id ?? identities[0]?.id
if (!identityId) throw new Error('Host mailbox has no submission identity')
// 3. Create the message + 4. submit it, in one request (back-referencing #msg).
const resp = await this.call(
apiUrl,
auth,
[CORE, MAIL, SUBMISSION],
[
[
'Email/set',
{
accountId,
create: {
msg: {
mailboxIds: { [fileMailbox]: true },
keywords: { $seen: true },
from: [{ email: access.email }],
to: [{ email: msg.to, name: msg.toName }],
subject: msg.subject,
bodyValues: {
t: { value: msg.text },
h: { value: msg.html },
},
textBody: [{ partId: 't', type: 'text/plain' }],
htmlBody: [{ partId: 'h', type: 'text/html' }],
attachments: [
{ blobId, type: 'text/calendar; method=PUBLISH; charset=utf-8', name: msg.icsFilename, disposition: 'attachment' },
],
},
},
},
'e',
],
[
'EmailSubmission/set',
{
accountId,
create: {
sub: {
emailId: '#msg',
identityId,
envelope: { mailFrom: { email: access.email }, rcptTo: [{ email: msg.to }] },
},
},
},
's',
],
],
)
const setRes = resp.find((r) => r[0] === 'Email/set')?.[1]
if (!setRes?.created?.msg) throw new Error(`Email/set failed: ${JSON.stringify(setRes?.notCreated ?? resp)}`)
const subRes = resp.find((r) => r[0] === 'EmailSubmission/set')?.[1]
if (!subRes?.created?.sub) throw new Error(`EmailSubmission/set failed: ${JSON.stringify(subRes?.notCreated ?? resp)}`)
}
private async call(apiUrl: string, auth: string, using: string[], methodCalls: any[]): Promise<MethodResponse[]> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
})
const text = await res.text()
if (!res.ok) throw new Error(`JMAP ${res.status}: ${text.slice(0, 200)}`)
const json = JSON.parse(text) as { methodResponses?: MethodResponse[] }
if (!json.methodResponses) throw new Error(`JMAP error: ${text.slice(0, 200)}`)
return json.methodResponses
}
}
@@ -0,0 +1,99 @@
import {
IsEnum,
IsHexColor,
IsInt,
IsMongoId,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator'
import type { LocationType } from '../../../schemas/event-type.schema.js'
const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
const LOCATIONS: LocationType[] = ['jitsi', 'phone', 'in_person', 'custom']
export class CreateEventTypeDto {
@IsString()
@Matches(SLUG, { message: 'slug must be lowercase, 2-40 chars, hyphen-separated' })
slug!: string
@IsString()
@IsNotEmpty({ message: 'title is required' })
@MaxLength(140)
title!: string
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string
@IsInt()
@Min(1)
@Max(1440)
durationMinutes!: number
@IsOptional()
@IsInt()
@Min(1)
@Max(240)
slotIntervalMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
@Max(720)
bufferBeforeMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
@Max(720)
bufferAfterMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
minimumNoticeMinutes?: number
@IsOptional()
@IsInt()
@Min(1)
@Max(730)
maximumDaysInFuture?: number
@IsMongoId()
availabilityScheduleId!: string
@IsOptional()
@IsEnum(LOCATIONS)
locationType?: LocationType
@IsOptional()
@IsString()
@MaxLength(500)
locationDetails?: string
@IsOptional()
@IsHexColor()
color?: string
}
// All fields optional for PATCH. (Avoids a mapped-types dependency.)
export class UpdateEventTypeDto {
@IsOptional() @IsString() @MaxLength(140) title?: string
@IsOptional() @IsString() @MaxLength(2000) description?: string
@IsOptional() @IsInt() @Min(1) @Max(1440) durationMinutes?: number
@IsOptional() @IsInt() @Min(1) @Max(240) slotIntervalMinutes?: number
@IsOptional() @IsInt() @Min(0) @Max(720) bufferBeforeMinutes?: number
@IsOptional() @IsInt() @Min(0) @Max(720) bufferAfterMinutes?: number
@IsOptional() @IsInt() @Min(0) minimumNoticeMinutes?: number
@IsOptional() @IsInt() @Min(1) @Max(730) maximumDaysInFuture?: number
@IsOptional() @IsMongoId() availabilityScheduleId?: string
@IsOptional() @IsEnum(LOCATIONS) locationType?: LocationType
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string
@IsOptional() @IsHexColor() color?: string
}
@@ -0,0 +1,72 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { EventType, EventTypeDocument, LocationType } from '../../schemas/event-type.schema.js'
export interface EventTypeInput {
slug: string
title: string
description?: string
durationMinutes: number
slotIntervalMinutes?: number
bufferBeforeMinutes?: number
bufferAfterMinutes?: number
minimumNoticeMinutes?: number
maximumDaysInFuture?: number
availabilityScheduleId: string
locationType?: LocationType
locationDetails?: string
color?: string
}
@Injectable()
export class EventTypesService {
constructor(@InjectModel(EventType.name) private readonly model: Model<EventTypeDocument>) {}
list(tenantId: Types.ObjectId, hostId: Types.ObjectId): Promise<EventTypeDocument[]> {
return this.model.find({ tenantId, hostId }).sort({ title: 1 }).exec()
}
async create(tenantId: Types.ObjectId, hostId: Types.ObjectId, input: EventTypeInput): Promise<EventTypeDocument> {
try {
return await this.model.create({
tenantId,
hostId,
...input,
availabilityScheduleId: new Types.ObjectId(input.availabilityScheduleId),
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('An event type with that slug already exists for this host.')
throw err
}
}
async get(tenantId: Types.ObjectId, id: string): Promise<EventTypeDocument> {
const doc = await this.model.findOne({ _id: id, tenantId }).exec()
if (!doc) throw new NotFoundException('Event type not found')
return doc
}
// Public resolution by (tenant, host, slug). Only active types are bookable.
async getActiveBySlug(tenantId: Types.ObjectId, hostId: Types.ObjectId, slug: string): Promise<EventTypeDocument> {
const doc = await this.model.findOne({ tenantId, hostId, slug, isActive: true }).exec()
if (!doc) throw new NotFoundException('Event type not found')
return doc
}
async update(tenantId: Types.ObjectId, id: string, input: Partial<EventTypeInput>): Promise<EventTypeDocument> {
const doc = await this.get(tenantId, id)
Object.assign(doc, {
...input,
availabilityScheduleId: input.availabilityScheduleId
? new Types.ObjectId(input.availabilityScheduleId)
: doc.availabilityScheduleId,
})
return doc.save()
}
async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
const res = await this.model.deleteOne({ _id: id, tenantId }).exec()
if (res.deletedCount === 0) throw new NotFoundException('Event type not found')
}
}
@@ -0,0 +1,25 @@
import { IsBoolean, IsMongoId, IsString, Matches, MaxLength } from 'class-validator'
const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class CreateHostDto {
// The workspace user to make bookable. Their mailbox, OIDC subject and display
// name are resolved server-side from this id — the client never supplies them.
@IsMongoId()
userId!: string
@IsString()
@Matches(SLUG, { message: 'slug must be lowercase, 2-40 chars, hyphen-separated' })
slug!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
}
export class SetHostActiveDto {
@IsBoolean()
isActive!: boolean
}
@@ -0,0 +1,119 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { StalwartClient } from '../../integrations/stalwart.client.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { User, UserDocument } from '../../schemas/user.schema.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
export interface CreateHostInput {
// Mongo id of the workspace user to make bookable. Mailbox, OIDC subject and
// display name are derived from this user, never supplied by the caller.
userId: string
slug: string
timezone: string
}
// Manages bookable hosts. Making a user bookable auto-provisions calendar access
// (app password) and discovers the host's default calendar — no user-facing
// "connect calendar" step. This is the integration coherence that is the product
// moat (CLAUDE.md §rationale).
@Injectable()
export class HostsService {
private readonly logger = new Logger(HostsService.name)
constructor(
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly stalwart: StalwartClient,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
) {}
async create(tenantId: Types.ObjectId, input: CreateHostInput): Promise<HostDocument> {
// Resolve the user (must belong to this tenant) and derive their identity.
const user = await this.userModel.findOne({ _id: input.userId, tenantIds: tenantId }).exec()
if (!user) throw new BadRequestException('User not found in this tenant.')
if (!user.mailboxAddress) {
throw new BadRequestException(`${user.name} has no workspace mailbox — only users with a mailbox can be hosts.`)
}
const email = user.mailboxAddress
// Prefer the account id captured at user provisioning; fall back to a lookup.
const accountId = user.stalwartAccountId ?? (await this.stalwart.findAccountIdByEmail(email))
if (!accountId) {
throw new BadRequestException(`No Stalwart mailbox found for ${email} — provision the mailbox first.`)
}
let host: HostDocument
try {
host = await this.hostModel.create({
tenantId,
authentikUserId: user.authentikSubjectId,
email,
displayName: user.name,
slug: input.slug,
timezone: input.timezone,
stalwartAccountId: accountId,
busyCalendarIds: [],
isActive: true,
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('A host with that slug or user already exists in this tenant.')
throw err
}
// Provision the credential, then discover + persist the default calendar.
try {
const access = await this.provisioner.provisionForHost(host)
const calendars = await this.gateway.listCalendars(access)
const defaultCalendarId = calendars[0]?.id
if (defaultCalendarId) {
host.defaultCalendarId = defaultCalendarId
host.busyCalendarIds = [defaultCalendarId]
await host.save()
} else {
this.logger.warn(`Host ${email} has no calendar to write to.`)
}
} catch (err) {
// Roll back the host row so a failed provisioning doesn't leave a half-host.
await this.hostModel.deleteOne({ _id: host._id }).exec()
throw new BadRequestException(`Could not provision calendar access for ${email}: ${(err as Error).message}`)
}
return host
}
list(tenantId: Types.ObjectId): Promise<HostDocument[]> {
return this.hostModel.find({ tenantId }).sort({ displayName: 1 }).exec()
}
async getById(tenantId: Types.ObjectId, hostId: string): Promise<HostDocument> {
const host = await this.hostModel.findOne({ _id: hostId, tenantId }).exec()
if (!host) throw new NotFoundException('Host not found')
return host
}
async getBySlug(tenantId: Types.ObjectId, slug: string): Promise<HostDocument> {
const host = await this.hostModel.findOne({ tenantId, slug }).exec()
if (!host) throw new NotFoundException('Host not found')
return host
}
async setActive(tenantId: Types.ObjectId, hostId: string, isActive: boolean): Promise<HostDocument> {
const host = await this.getById(tenantId, hostId)
host.isActive = isActive
return host.save()
}
async rotateCredential(tenantId: Types.ObjectId, hostId: string): Promise<void> {
const host = await this.getById(tenantId, hostId)
await this.provisioner.provisionForHost(host)
}
async remove(tenantId: Types.ObjectId, hostId: string): Promise<void> {
const host = await this.getById(tenantId, hostId)
await this.provisioner.deprovisionForHost(host._id, host.stalwartAccountId)
await this.hostModel.deleteOne({ _id: host._id }).exec()
}
}
@@ -0,0 +1,63 @@
import { IsEmail, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'
// IANA timezone sanity check (Area/Location, optionally a third segment). Not a
// full tz-database membership test — Luxon rejects unknown zones downstream.
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class SlotsQueryDto {
@IsISO8601()
from!: string
@IsISO8601()
to!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
}
export class CreateHoldDto {
@IsISO8601()
startUtc!: string
}
export class CreateBookingDto {
@IsISO8601()
startUtc!: string
@IsString()
@MaxLength(120)
attendeeName!: string
@IsEmail()
@MaxLength(254)
attendeeEmail!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'attendeeTimezone must be an IANA zone like Europe/Copenhagen' })
attendeeTimezone!: string
@IsOptional()
@IsString()
@MaxLength(2000)
attendeeNotes?: string
@IsOptional()
@IsString()
@MaxLength(64)
holdId?: string
}
export class CancelBookingDto {
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string
}
export class RescheduleBookingDto {
@IsISO8601()
startUtc!: string
}
@@ -0,0 +1,156 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
import { TenantsService } from '../../tenants/tenants.service.js'
import type { BookingContext } from '../bookings/bookings.service.js'
import { BookingsService } from '../bookings/bookings.service.js'
import { EventTypesService } from '../event-types/event-types.service.js'
import { HostsService } from '../hosts/hosts.service.js'
import { SlotService } from '../slots/slot.service.js'
import { BookingDocument } from '../../schemas/booking.schema.js'
import {
CancelBookingDto,
CreateBookingDto,
CreateHoldDto,
RescheduleBookingDto,
SlotsQueryDto,
} from './dto/public-dtos.js'
import { PublicSchedulingService } from './public-scheduling.service.js'
// Public booking surface — unauthenticated, served to the booking.dezky.eu app.
// Rate-limited per-IP (anti-abuse). Returns UTC instants; the client renders in
// the visitor's tz. No internal ids or host PII beyond display name leak out.
@Controller('api/v1/public')
@UseGuards(ThrottlerGuard)
export class PublicSchedulingController {
constructor(
private readonly publicSvc: PublicSchedulingService,
private readonly slots: SlotService,
private readonly bookings: BookingsService,
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService,
) {}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug')
async info(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
return this.publicSvc.publicInfo(ctx)
}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug/slots')
async availableSlots(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Query() q: SlotsQueryDto,
) {
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))
return {
timezone: q.timezone,
durationMinutes: ctx.eventType.durationMinutes,
slots: slots.map((s) => ({ startUtc: s.startUtc.toISOString(), endUtc: s.endUtc.toISOString() })),
}
}
// Tighter limit on the write endpoints than the read default.
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post(':tenantSlug/:hostSlug/:eventTypeSlug/holds')
async createHold(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateHoldDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc))
return { holdId, expiresAt: expiresAt.toISOString() }
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post(':tenantSlug/:hostSlug/:eventTypeSlug/bookings')
async createBooking(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateBookingDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const booking = await this.bookings.confirm(ctx, {
startUtc: new Date(dto.startUtc),
attendeeName: dto.attendeeName,
attendeeEmail: dto.attendeeEmail,
attendeeTimezone: dto.attendeeTimezone,
attendeeNotes: dto.attendeeNotes,
holdId: dto.holdId,
})
return this.publicBooking(booking, ctx)
}
@Get('bookings/:manageToken')
async manageView(@Param('manageToken') manageToken: string) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
return this.publicBooking(booking, ctx)
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('bookings/:manageToken/cancel')
async cancel(@Param('manageToken') manageToken: string, @Body() dto: CancelBookingDto) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
const updated = await this.bookings.cancel(manageToken, dto.reason, ctx)
return this.publicBooking(updated, ctx)
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('bookings/:manageToken/reschedule')
async reschedule(@Param('manageToken') manageToken: string, @Body() dto: RescheduleBookingDto) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
const fresh = await this.bookings.reschedule(manageToken, new Date(dto.startUtc), ctx)
return this.publicBooking(fresh, ctx)
}
// Rebuild the booking context (tenant + host + event type) from a stored booking.
private async contextFromBooking(booking: BookingDocument): Promise<BookingContext> {
const tenant = await this.tenants.findOneById(booking.tenantId)
const host = await this.hosts.getById(tenant._id, String(booking.hostId))
const eventType = await this.eventTypes.get(tenant._id, String(booking.eventTypeId))
return {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
}
private publicBooking(booking: BookingDocument, ctx: BookingContext) {
return {
manageToken: booking.manageToken,
status: booking.status,
startUtc: booking.startUtc.toISOString(),
endUtc: booking.endUtc.toISOString(),
attendeeName: booking.attendeeName,
attendeeEmail: booking.attendeeEmail,
attendeeTimezone: booking.attendeeTimezone,
attendeeNotes: booking.attendeeNotes ?? null,
locationType: booking.locationType ?? null,
locationUrl: booking.locationUrl ?? null,
branding: {
tenantSlug: ctx.tenant.slug,
name: ctx.tenant.name,
brandColor: ctx.tenant.brandColor ?? null,
},
host: { slug: ctx.host.slug, displayName: ctx.host.displayName, timezone: ctx.host.timezone },
eventType: {
slug: ctx.eventType.slug,
title: ctx.eventType.title,
durationMinutes: ctx.eventType.durationMinutes,
},
}
}
}
@@ -0,0 +1,52 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { TenantsService } from '../../tenants/tenants.service.js'
import { EventTypesService } from '../event-types/event-types.service.js'
import { HostsService } from '../hosts/hosts.service.js'
import type { BookingContext } from '../bookings/bookings.service.js'
// Resolves the unauthenticated (tenantSlug, hostSlug, eventTypeSlug) triple into
// a fully-loaded BookingContext, and shapes the public-facing info payload (no
// internal ids, no PII). Only active hosts + event types are bookable.
@Injectable()
export class PublicSchedulingService {
constructor(
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService,
) {}
async resolveContext(tenantSlug: string, hostSlug: string, eventTypeSlug: string): Promise<BookingContext> {
const tenant = await this.tenants.findOneBySlug(tenantSlug)
const host = await this.hosts.getBySlug(tenant._id, hostSlug)
if (!host.isActive) throw new BadRequestException('This booking page is not available.')
const eventType = await this.eventTypes.getActiveBySlug(tenant._id, host._id, eventTypeSlug)
return {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
}
// Public event-type + host info + tenant branding, for the booking page header.
publicInfo(ctx: BookingContext) {
return {
branding: {
tenantSlug: ctx.tenant.slug,
name: ctx.tenant.name,
brandColor: ctx.tenant.brandColor ?? null,
},
host: {
slug: ctx.host.slug,
displayName: ctx.host.displayName,
timezone: ctx.host.timezone,
},
eventType: {
slug: ctx.eventType.slug,
title: ctx.eventType.title,
description: ctx.eventType.description ?? null,
durationMinutes: ctx.eventType.durationMinutes,
locationType: ctx.eventType.locationType,
},
}
}
}
@@ -0,0 +1,207 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common'
import { Types } from 'mongoose'
import { ActorService } from '../auth/actor.service.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { AvailabilityService } from './availability/availability.service.js'
import { CreateAvailabilityDto, UpdateAvailabilityDto } from './availability/dto/availability-dtos.js'
import { BookingsService, type BookingContext } from './bookings/bookings.service.js'
import { CancelBookingDto, RescheduleBookingDto } from './public/dto/public-dtos.js'
import { CreateEventTypeDto, UpdateEventTypeDto } from './event-types/dto/event-type-dtos.js'
import { EventTypesService } from './event-types/event-types.service.js'
import { CreateHostDto, SetHostActiveDto } from './hosts/dto/create-host.dto.js'
import { HostsService } from './hosts/hosts.service.js'
// Authenticated host/admin scheduling config (OIDC via Authentik). Tenant-scoped
// and gated exactly like the rest of platform-api: platformAdmin OR a member of
// the tenant. Mounted under /api/v1 per the scheduling routing decision.
@Controller('api/v1/tenants/:slug/scheduling')
@UseGuards(JwtAuthGuard)
export class SchedulingAdminController {
constructor(
private readonly actor: ActorService,
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly availability: AvailabilityService,
private readonly eventTypes: EventTypesService,
private readonly bookings: BookingsService,
) {}
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return tenant._id
}
// ── Hosts ──
@Get('hosts')
async listHosts(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.list(await this.gate(slug, jwt))
}
@Post('hosts')
async createHost(@Param('slug') slug: string, @Body() dto: CreateHostDto, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.create(await this.gate(slug, jwt), dto)
}
@Get('hosts/:hostId')
async getHost(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.getById(await this.gate(slug, jwt), hostId)
}
@Patch('hosts/:hostId/active')
async setHostActive(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: SetHostActiveDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.hosts.setActive(await this.gate(slug, jwt), hostId, dto.isActive)
}
@Post('hosts/:hostId/rotate-credential')
@HttpCode(204)
async rotateCredential(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.hosts.rotateCredential(await this.gate(slug, jwt), hostId)
}
@Delete('hosts/:hostId')
@HttpCode(204)
async removeHost(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.hosts.remove(await this.gate(slug, jwt), hostId)
}
// ── Availability schedules ──
@Get('hosts/:hostId/availability')
async listAvailability(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenantId = await this.gate(slug, jwt)
return this.availability.list(tenantId, new Types.ObjectId(hostId))
}
@Post('hosts/:hostId/availability')
async createAvailability(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: CreateAvailabilityDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.availability.create(tenantId, new Types.ObjectId(hostId), dto)
}
@Patch('availability/:id')
async updateAvailability(
@Param('slug') slug: string,
@Param('id') id: string,
@Body() dto: UpdateAvailabilityDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.availability.update(await this.gate(slug, jwt), id, dto)
}
@Delete('availability/:id')
@HttpCode(204)
async removeAvailability(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.availability.remove(await this.gate(slug, jwt), id)
}
// ── Event types ──
@Get('hosts/:hostId/event-types')
async listEventTypes(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenantId = await this.gate(slug, jwt)
return this.eventTypes.list(tenantId, new Types.ObjectId(hostId))
}
@Post('hosts/:hostId/event-types')
async createEventType(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: CreateEventTypeDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.eventTypes.create(tenantId, new Types.ObjectId(hostId), dto)
}
@Patch('event-types/:id')
async updateEventType(
@Param('slug') slug: string,
@Param('id') id: string,
@Body() dto: UpdateEventTypeDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.eventTypes.update(await this.gate(slug, jwt), id, dto)
}
@Delete('event-types/:id')
@HttpCode(204)
async removeEventType(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.eventTypes.remove(await this.gate(slug, jwt), id)
}
// ── Bookings ──
@Get('bookings')
async listBookings(
@Param('slug') slug: string,
@Query('hostId') hostId: string | undefined,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.bookings.listForTenant(tenantId, hostId ? new Types.ObjectId(hostId) : undefined)
}
@Post('bookings/:bookingId/cancel')
async cancelBooking(
@Param('slug') slug: string,
@Param('bookingId') bookingId: string,
@Body() dto: CancelBookingDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
const { booking, ctx } = await this.resolveBookingCtx(tenantId, bookingId)
return this.bookings.cancelResolved(booking, dto.reason, ctx)
}
@Post('bookings/:bookingId/reschedule')
async rescheduleBooking(
@Param('slug') slug: string,
@Param('bookingId') bookingId: string,
@Body() dto: RescheduleBookingDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
const { booking, ctx } = await this.resolveBookingCtx(tenantId, bookingId)
return this.bookings.rescheduleResolved(booking, new Date(dto.startUtc), ctx)
}
// Load a tenant-scoped booking + its full booking context (tenant + host + event type).
private async resolveBookingCtx(tenantId: Types.ObjectId, bookingId: string) {
const booking = await this.bookings.getForTenant(tenantId, bookingId)
const tenant = await this.tenants.findOneById(tenantId)
const host = await this.hosts.getById(tenantId, String(booking.hostId))
const eventType = await this.eventTypes.get(tenantId, String(booking.eventTypeId))
const ctx: BookingContext = {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
return { booking, ctx }
}
}
@@ -0,0 +1,57 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ThrottlerModule } from '@nestjs/throttler'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { AvailabilitySchedule, AvailabilityScheduleSchema } from '../schemas/availability-schedule.schema.js'
import { Booking, BookingSchema } from '../schemas/booking.schema.js'
import { EventType, EventTypeSchema } from '../schemas/event-type.schema.js'
import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { AvailabilityService } from './availability/availability.service.js'
import { BookingsService } from './bookings/bookings.service.js'
import { JmapMailer } from './email/jmap-mailer.service.js'
import { EventTypesService } from './event-types/event-types.service.js'
import { HostsService } from './hosts/hosts.service.js'
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
import { PublicSchedulingService } from './public/public-scheduling.service.js'
import { SchedulingAdminController } from './scheduling-admin.controller.js'
import { SlotService } from './slots/slot.service.js'
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.js'
// dezky Scheduling — Calendly-style booking on top of Stalwart calendars. Public
// pages (booking.dezky.eu) hit the unauthenticated /api/v1/public routes; host
// config sits behind the workspace-portal OIDC. The Stalwart integration is
// isolated in StalwartCalendarModule so it can be extracted later.
@Module({
imports: [
MongooseModule.forFeature([
{ name: Host.name, schema: HostSchema },
{ name: AvailabilitySchedule.name, schema: AvailabilityScheduleSchema },
{ name: EventType.name, schema: EventTypeSchema },
{ name: Booking.name, schema: BookingSchema },
{ name: SlotLock.name, schema: SlotLockSchema },
{ name: User.name, schema: UserSchema },
]),
// Per-IP rate limiting for the public booking endpoints (default read limit;
// write endpoints tighten it via @Throttle).
ThrottlerModule.forRoot({ throttlers: [{ name: 'default', ttl: 60_000, limit: 60 }] }),
AuthModule,
TenantsModule,
IntegrationsModule, // StalwartClient — host→account lookup during onboarding
StalwartCalendarModule,
],
controllers: [SchedulingAdminController, PublicSchedulingController],
providers: [
HostsService,
AvailabilityService,
EventTypesService,
SlotService,
BookingsService,
PublicSchedulingService,
JmapMailer,
],
})
export class SchedulingModule {}
@@ -0,0 +1,146 @@
import { DateTime } from 'luxon'
import type { WeeklyRule } from '../../schemas/availability-schedule.schema.js'
import { computeSlots, SlotComputeInput } from './slot-computer.js'
// Pure slot-math tests, with a focus on the DST boundary (DoD: "display is
// correct across a DST boundary"). All assertions compute the expected UTC with
// Luxon so they stay correct regardless of the machine's local tz.
const TZ = 'Europe/Copenhagen'
// 09:0017:00 every day, in minutes from local midnight.
const allWeek = (start = 540, end = 1020): WeeklyRule[] =>
Array.from({ length: 7 }, (_, dow) => ({ dayOfWeek: dow, intervals: [{ startMinute: start, endMinute: end }] }))
function baseInput(over: Partial<SlotComputeInput> = {}): SlotComputeInput {
return {
durationMinutes: 60,
slotIntervalMinutes: 60,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
minimumNoticeMinutes: 0,
maximumDaysInFuture: 365,
scheduleTimezone: TZ,
weeklyRules: allWeek(),
dateOverrides: [],
busy: [],
now: new Date('2026-06-01T00:00:00Z'),
fromUtc: new Date('2026-06-01T00:00:00Z'),
toUtc: new Date('2026-06-02T00:00:00Z'),
...over,
}
}
const iso = (s: string, tz = TZ) => DateTime.fromISO(s, { zone: tz }).toUTC().toISO()
const isoSet = (slots: { startUtc: Date }[]) => slots.map((s) => s.startUtc.toISOString().replace('.000Z', 'Z'))
describe('computeSlots', () => {
it('generates one slot per step within the daily window', () => {
// 09:0017:00, 60-min slots → 09,10,11,12,13,14,15,16 = 8 slots.
const slots = computeSlots(baseInput())
expect(slots).toHaveLength(8)
expect(slots[0].startUtc.toISOString()).toBe(new Date(iso('2026-06-01T09:00')!).toISOString())
expect(slots[7].startUtc.toISOString()).toBe(new Date(iso('2026-06-01T16:00')!).toISOString())
})
it('respects slotIntervalMinutes independent of duration', () => {
// 30-min slots, 60-min duration, 09:0011:00 window → 09:00, 09:30, 10:00.
const slots = computeSlots(
baseInput({ durationMinutes: 60, slotIntervalMinutes: 30, weeklyRules: allWeek(540, 660) }),
)
expect(slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('HH:mm'))).toEqual([
'09:00', '09:30', '10:00',
])
})
it('drops slots inside the minimum-notice window', () => {
const now = new Date('2026-06-01T09:30:00Z') // 11:30 local CEST
const slots = computeSlots(baseInput({ now, minimumNoticeMinutes: 120 }))
// Earliest allowed = 11:30Z. First remaining slot is 12:00 local = 10:00Z...
// 09:30Z + 120min = 11:30Z; first slot start >= 11:30Z is 14:00 local (12:00Z).
expect(slots[0].startUtc.toISOString()).toBe(new Date('2026-06-01T12:00:00Z').toISOString())
})
it('removes slots overlapping a busy interval expanded by buffers', () => {
// Busy 10:0010:30 local (08:0008:30Z) with 30-min buffers blocks 09:00..11:00.
const busy = [{ startUtc: new Date(iso('2026-06-01T10:00')!), endUtc: new Date(iso('2026-06-01T10:30')!) }]
const slots = computeSlots(baseInput({ bufferBeforeMinutes: 30, bufferAfterMinutes: 30, busy }))
const local = slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('HH:mm'))
// 10:00 overlaps directly; 09:00's +30 after-buffer (ends 08:30Z) overlaps the
// busy block. 11:00's -30 before-buffer ends exactly at 08:30Z — touching, not
// overlapping — so it stays available.
expect(local).not.toContain('10:00')
expect(local).not.toContain('09:00')
expect(local).toContain('11:00')
expect(local).toContain('12:00')
})
it('honours the booking horizon (maximumDaysInFuture)', () => {
const slots = computeSlots(
baseInput({
maximumDaysInFuture: 1,
fromUtc: new Date('2026-06-01T00:00:00Z'),
toUtc: new Date('2026-06-10T00:00:00Z'),
}),
)
// now=2026-06-01T00:00Z, horizon +1 day = 2026-06-02T00:00Z. Only 1st's slots.
const days = new Set(slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('yyyy-MM-dd')))
expect([...days]).toEqual(['2026-06-01'])
})
// ── DST ────────────────────────────────────────────────────────────────────
it('maps the same local time to different UTC across the spring-forward boundary', () => {
// Copenhagen DST starts 2026-03-29. 09:00 local: 28th = CET (+1) → 08:00Z;
// 30th = CEST (+2) → 07:00Z.
const slots = computeSlots(
baseInput({
durationMinutes: 60,
slotIntervalMinutes: 60,
weeklyRules: allWeek(540, 600), // 09:0010:00
now: new Date('2026-03-01T00:00:00Z'),
fromUtc: new Date('2026-03-28T00:00:00Z'),
toUtc: new Date('2026-03-31T00:00:00Z'),
}),
)
const set = isoSet(slots)
expect(set).toContain('2026-03-28T08:00:00Z') // before DST (+1)
expect(set).toContain('2026-03-30T07:00:00Z') // after DST (+2)
})
it('never offers a slot at a non-existent spring-forward local time', () => {
// The gap is 02:00→03:00 local on 2026-03-29. Offer 02:0003:00 that day;
// 30-min slots at 02:00 and 02:30 do not exist and must be dropped.
const slots = computeSlots(
baseInput({
durationMinutes: 30,
slotIntervalMinutes: 30,
weeklyRules: [],
dateOverrides: [{ date: '2026-03-29', isUnavailable: false, intervals: [{ startMinute: 120, endMinute: 180 }] }],
now: new Date('2026-03-01T00:00:00Z'),
fromUtc: new Date('2026-03-29T00:00:00Z'),
toUtc: new Date('2026-03-30T00:00:00Z'),
}),
)
expect(slots).toHaveLength(0)
})
it('applies date overrides: unavailable day yields no slots', () => {
const slots = computeSlots(
baseInput({
dateOverrides: [{ date: '2026-06-01', isUnavailable: true, intervals: [] }],
}),
)
expect(slots).toHaveLength(0)
})
it('date override intervals replace the weekly rule for that date', () => {
const slots = computeSlots(
baseInput({
weeklyRules: allWeek(540, 1020), // 0917 normally
dateOverrides: [{ date: '2026-06-01', isUnavailable: false, intervals: [{ startMinute: 780, endMinute: 840 }] }], // 13:0014:00
}),
)
expect(slots).toHaveLength(1)
expect(DateTime.fromJSDate(slots[0].startUtc).setZone(TZ).toFormat('HH:mm')).toBe('13:00')
})
})
@@ -0,0 +1,119 @@
import { DateTime } from 'luxon'
import type { DateOverride, MinuteInterval, WeeklyRule } from '../../schemas/availability-schedule.schema.js'
// Pure slot computation (§8.1). No I/O, no Mongoose — takes the rules + the known
// busy intervals and returns free UTC slots. All DST/offset math goes through
// Luxon; there is no manual offset arithmetic anywhere. Unit-tested across a
// Europe/Copenhagen DST boundary.
export interface SlotComputeInput {
durationMinutes: number
slotIntervalMinutes: number
bufferBeforeMinutes: number
bufferAfterMinutes: number
minimumNoticeMinutes: number
maximumDaysInFuture: number
scheduleTimezone: string // IANA — availability is authored in this zone
weeklyRules: WeeklyRule[]
dateOverrides: DateOverride[]
// Already-busy UTC intervals (calendar free/busy + confirmed bookings + live holds).
busy: Array<{ startUtc: Date; endUtc: Date }>
now: Date
fromUtc: Date // requested window start
toUtc: Date // requested window end (exclusive)
}
export interface ComputedSlot {
startUtc: Date
endUtc: Date
}
export function computeSlots(input: SlotComputeInput): ComputedSlot[] {
const {
durationMinutes, slotIntervalMinutes, bufferBeforeMinutes, bufferAfterMinutes,
minimumNoticeMinutes, maximumDaysInFuture, scheduleTimezone,
weeklyRules, dateOverrides, busy, now, fromUtc, toUtc,
} = input
const zone = scheduleTimezone
const earliest = new Date(Math.max(now.getTime() + minimumNoticeMinutes * 60_000, fromUtc.getTime()))
const horizon = new Date(now.getTime() + maximumDaysInFuture * 86_400_000)
const latest = new Date(Math.min(horizon.getTime(), toUtc.getTime()))
if (earliest >= latest) return []
const overridesByDate = new Map(dateOverrides.map((o) => [o.date, o]))
// Walk local calendar dates in the schedule zone from `earliest` to `latest`.
// Start one day early so a slot near a tz/day boundary isn't missed.
let cursor = DateTime.fromJSDate(earliest, { zone }).startOf('day').minus({ days: 1 })
const end = DateTime.fromJSDate(latest, { zone }).endOf('day')
const slots: ComputedSlot[] = []
while (cursor <= end) {
const isoDate = cursor.toFormat('yyyy-MM-dd')
const intervals = intervalsForDate(cursor, isoDate, weeklyRules, overridesByDate)
for (const intv of intervals) {
for (
let m = intv.startMinute;
m + durationMinutes <= intv.endMinute;
m += slotIntervalMinutes
) {
const hour = Math.floor(m / 60)
const minute = m % 60
const startLocal = cursor.set({ hour, minute, second: 0, millisecond: 0 })
// Non-existent local time (spring-forward gap): Luxon silently shifts the
// wall clock forward rather than flagging invalid, so detect the gap by a
// round-trip check and skip — we never offer a slot at a time that didn't
// exist locally.
if (!startLocal.isValid || startLocal.hour !== hour || startLocal.minute !== minute) continue
const startUtc = startLocal.toUTC().toJSDate()
const endUtc = new Date(startUtc.getTime() + durationMinutes * 60_000)
if (startUtc < earliest || startUtc >= latest) continue
if (overlapsBusy(startUtc, endUtc, bufferBeforeMinutes, bufferAfterMinutes, busy)) continue
slots.push({ startUtc, endUtc })
}
}
cursor = cursor.plus({ days: 1 })
}
// De-dupe (a fall-back DST hour can yield two identical local→UTC mappings) and sort.
const seen = new Set<number>()
return slots
.filter((s) => (seen.has(s.startUtc.getTime()) ? false : seen.add(s.startUtc.getTime())))
.sort((a, b) => a.startUtc.getTime() - b.startUtc.getTime())
}
function intervalsForDate(
date: DateTime,
isoDate: string,
weeklyRules: WeeklyRule[],
overrides: Map<string, DateOverride>,
): MinuteInterval[] {
const override = overrides.get(isoDate)
if (override) {
if (override.isUnavailable) return []
return override.intervals ?? []
}
const jsDow = date.weekday % 7 // luxon 1=Mon..7=Sun → 0=Sun..6=Sat
return weeklyRules.filter((r) => r.dayOfWeek === jsDow).flatMap((r) => r.intervals ?? [])
}
// A candidate conflicts if the slot, padded by its before/after buffers, overlaps
// any busy interval.
function overlapsBusy(
startUtc: Date,
endUtc: Date,
bufferBefore: number,
bufferAfter: number,
busy: Array<{ startUtc: Date; endUtc: Date }>,
): boolean {
const padStart = startUtc.getTime() - bufferBefore * 60_000
const padEnd = endUtc.getTime() + bufferAfter * 60_000
for (const b of busy) {
if (padStart < b.endUtc.getTime() && padEnd > b.startUtc.getTime()) return true
}
return false
}
@@ -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 }))
}
}
@@ -0,0 +1,48 @@
// Transport-agnostic calendar gateway contract. JMAP is the only implementation
// today (CalDAV fallback shelved after Phase 0), but callers depend on this
// interface so the transport stays an implementation detail.
// Decrypted, ready-to-use access to one host's Stalwart calendar. Built by the
// CredentialProvisioner from the Host + its encrypted StalwartCredential.
export interface HostCalendarAccess {
email: string // host mailbox address (HTTP Basic user)
secret: string // decrypted app password (HTTP Basic pass) — never logged
jmapSessionUrl: string // .well-known/jmap on the internal Stalwart hostname
defaultCalendarId?: string
busyCalendarIds: string[]
}
export interface Interval {
startUtc: Date
endUtc: Date
}
export interface CalendarRef {
id: string
name: string
}
// The event we write when a booking is confirmed. Times are UTC; hostTimezone is
// the IANA zone the JSCalendar local start/end are expressed in.
export interface BookingEvent {
uid: string // client-generated, idempotent across retries
title: string
description?: string
startUtc: Date
endUtc: Date
hostTimezone: string
location?: string
hostEmail: string
attendeeName: string
attendeeEmail: string
}
export interface CalendarGateway {
listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]>
// Busy intervals within [fromUtc, toUtc), recurrence already expanded, in UTC.
getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]>
// Returns the server-assigned event id (distinct from the UID).
createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }>
deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void>
validateCredential(access: HostCalendarAccess): Promise<boolean>
}
@@ -0,0 +1,102 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { StalwartClient } from '../../integrations/stalwart.client.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { StalwartCredential, StalwartCredentialDocument } from '../../schemas/stalwart-credential.schema.js'
import { CredentialCipher } from '../crypto/credential-cipher.js'
import type { HostCalendarAccess } from './calendar-gateway.types.js'
// Owns the lifecycle of a host's encrypted Stalwart calendar credential. At
// onboarding it mints an app password on-behalf via the admin JMAP and stores it
// AES-256-GCM-encrypted; at read time it decrypts into a HostCalendarAccess the
// gateway can use. No user-facing "connect calendar" step — this is the whole
// point of owning both identity and the calendar server (CLAUDE.md §rationale).
@Injectable()
export class CredentialProvisioner {
private readonly logger = new Logger(CredentialProvisioner.name)
private readonly sessionUrl: string
constructor(
private readonly stalwart: StalwartClient,
private readonly cipher: CredentialCipher,
config: ConfigService,
@InjectModel(StalwartCredential.name)
private readonly credModel: Model<StalwartCredentialDocument>,
) {
// .well-known/jmap on the internal Stalwart hostname (STALWART_API_URL).
const base = config.getOrThrow<string>('STALWART_API_URL').replace(/\/$/, '')
this.sessionUrl = `${base}/.well-known/jmap`
}
// Mint + persist (or rotate) a host's app-password credential. Destroys any
// prior app password first so we never leak orphaned credentials. Returns the
// freshly built access so the caller can immediately list calendars.
async provisionForHost(host: HostDocument): Promise<HostCalendarAccess> {
const existing = await this.credModel.findOne({ hostId: host._id }).exec()
if (existing?.appPasswordId) {
await this.stalwart
.deleteAppPassword(host.stalwartAccountId, existing.appPasswordId)
.catch((e) => this.logger.warn(`Could not remove old app password for ${host.email}: ${e.message}`))
}
const { id, secret } = await this.stalwart.createAppPassword(
host.stalwartAccountId,
`dezky-scheduling:${host.slug}`,
)
const sealed = this.cipher.seal(secret)
await this.credModel.updateOne(
{ hostId: host._id },
{
$set: {
tenantId: host.tenantId,
type: 'app_password',
encryptedSecret: sealed.encryptedSecret,
iv: sealed.iv,
authTag: sealed.authTag,
appPasswordId: id,
jmapSessionUrl: this.sessionUrl,
lastValidatedAt: new Date(),
},
},
{ upsert: true },
)
this.logger.log(`Provisioned scheduling credential for host ${host.email}`)
return {
email: host.email,
secret,
jmapSessionUrl: this.sessionUrl,
defaultCalendarId: host.defaultCalendarId,
busyCalendarIds: host.busyCalendarIds?.length ? host.busyCalendarIds : host.defaultCalendarId ? [host.defaultCalendarId] : [],
}
}
// Decrypt a host's stored credential into ready-to-use access. Throws if the
// host was never provisioned.
async resolveAccess(host: HostDocument): Promise<HostCalendarAccess> {
const cred = await this.credModel.findOne({ hostId: host._id }).exec()
if (!cred) throw new NotFoundException(`Host ${host.email} has no scheduling credential — provision first`)
const secret = this.cipher.open({ encryptedSecret: cred.encryptedSecret, iv: cred.iv, authTag: cred.authTag })
return {
email: host.email,
secret,
jmapSessionUrl: cred.jmapSessionUrl,
defaultCalendarId: host.defaultCalendarId,
busyCalendarIds: host.busyCalendarIds?.length ? host.busyCalendarIds : host.defaultCalendarId ? [host.defaultCalendarId] : [],
}
}
// Remove a host's app password from Stalwart and delete the stored credential.
async deprovisionForHost(hostId: Types.ObjectId, stalwartAccountId: string): Promise<void> {
const cred = await this.credModel.findOne({ hostId }).exec()
if (cred?.appPasswordId) {
await this.stalwart
.deleteAppPassword(stalwartAccountId, cred.appPasswordId)
.catch((e) => this.logger.warn(`Could not remove app password: ${e.message}`))
}
await this.credModel.deleteOne({ hostId }).exec()
}
}
@@ -0,0 +1,206 @@
import { Injectable, Logger } from '@nestjs/common'
import type {
BookingEvent,
CalendarGateway,
CalendarRef,
HostCalendarAccess,
Interval,
} from './calendar-gateway.types.js'
// JMAP-for-Calendars implementation against Stalwart (verified in Phase 0 — see
// reference_stalwart_calendar_jmap). Per-host auth is HTTP Basic with the host's
// app password. Free/busy uses Principal/getAvailability (server expands
// recurrences and returns UTC intervals). Bookings are written via
// CalendarEvent/set; the attendee is added with scheduleAgent:"client" so the
// server never sends an iMIP invite (dezky sends its own branded email), and the
// attendee is ALSO folded into the title/description because Stalwart's Community
// build does not persist the participants block (Phase 0 finding) — that keeps
// the booking legible to the host on their own calendar regardless.
const CORE = 'urn:ietf:params:jmap:core'
const CALENDARS = 'urn:ietf:params:jmap:calendars'
const PRINCIPALS = 'urn:ietf:params:jmap:principals'
const AVAILABILITY = 'urn:ietf:params:jmap:principals:availability'
interface JmapSession {
apiUrl: string
primaryAccounts: Record<string, string>
}
type MethodCall = [string, Record<string, unknown>, string]
type MethodResponse = [string, Record<string, any>, string]
@Injectable()
export class JmapCalendarGateway implements CalendarGateway {
private readonly logger = new Logger(JmapCalendarGateway.name)
private authHeader(access: HostCalendarAccess): string {
return `Basic ${Buffer.from(`${access.email}:${access.secret}`).toString('base64')}`
}
// Method-call endpoint, derived from the session URL so we always hit the
// internal Stalwart hostname (the session object's apiUrl may advertise the
// public Traefik FQDN, which Node's fetch can't reach with the mkcert cert).
private apiUrl(access: HostCalendarAccess): string {
return access.jmapSessionUrl.replace(/\/\.well-known\/jmap$/, '/jmap')
}
private async session(access: HostCalendarAccess): Promise<JmapSession> {
const res = await fetch(access.jmapSessionUrl, { headers: { Authorization: this.authHeader(access) } })
if (!res.ok) throw new Error(`JMAP session ${res.status} for ${access.email}`)
return (await res.json()) as JmapSession
}
private async call(access: HostCalendarAccess, using: string[], methodCalls: MethodCall[]): Promise<MethodResponse[]> {
const res = await fetch(this.apiUrl(access), {
method: 'POST',
headers: { Authorization: this.authHeader(access), 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
})
const text = await res.text()
if (!res.ok) throw new Error(`JMAP ${res.status}: ${text.slice(0, 200)}`)
const json = JSON.parse(text) as { methodResponses?: MethodResponse[] }
if (!json.methodResponses) throw new Error(`JMAP error: ${text.slice(0, 200)}`)
return json.methodResponses
}
private calAccountId(session: JmapSession): string {
const id = session.primaryAccounts?.[CALENDARS]
if (!id) throw new Error('Stalwart account has no calendar capability')
return id
}
async validateCredential(access: HostCalendarAccess): Promise<boolean> {
try {
await this.session(access)
return true
} catch (err) {
this.logger.warn(`Credential validation failed for ${access.email}: ${(err as Error).message}`)
return false
}
}
async listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
const list = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? []) as Array<{ id: string; name: string }>
return list.map((c) => ({ id: c.id, name: c.name }))
}
async getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(
access,
[CORE, PRINCIPALS, AVAILABILITY],
[
[
'Principal/getAvailability',
{ accountId, id: accountId, utcStart: fromUtc.toISOString(), utcEnd: toUtc.toISOString() },
'a',
],
],
)
const out = resp.find((r) => r[0] === 'Principal/getAvailability')
if (!out) {
const err = resp.find((r) => r[0] === 'error')?.[1]
throw new Error(`getAvailability failed: ${JSON.stringify(err)}`)
}
const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>
// Drop free/tentative-cancelled markers; count confirmed + tentative as busy.
return list
.filter((b) => b.busyStatus !== 'free')
.map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) }))
}
async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const calendarId = access.defaultCalendarId ?? (await this.firstCalendarId(access, accountId))
const startLocal = toJsCalLocal(event.startUtc, event.hostTimezone)
const durationIso = isoDuration(event.endUtc.getTime() - event.startUtc.getTime())
const descriptionParts = [
`Booked by: ${event.attendeeName} <${event.attendeeEmail}>`,
event.location ? `Location: ${event.location}` : '',
event.description ?? '',
].filter(Boolean)
const jsEvent: Record<string, unknown> = {
'@type': 'Event',
uid: event.uid,
calendarIds: { [calendarId]: true },
title: `${event.title}${event.attendeeName}`,
description: descriptionParts.join('\n'),
start: startLocal,
timeZone: event.hostTimezone,
duration: durationIso,
status: 'confirmed',
...(event.location ? { locations: { '1': { '@type': 'Location', name: event.location } } } : {}),
// Defensive: even though Community does not persist participants or send
// iMIP, mark the attendee scheduleAgent:client so a future server that DOES
// auto-send still won't double-send (dezky owns attendee notification).
replyTo: { imip: `mailto:${event.hostEmail}` },
participants: {
host: { '@type': 'Participant', email: event.hostEmail, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
attendee: {
'@type': 'Participant',
name: event.attendeeName,
email: event.attendeeEmail,
roles: { attendee: true },
participationStatus: 'accepted',
scheduleAgent: 'client',
},
},
}
const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, create: { e: jsEvent } }, 's']])
const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
const id = result?.created?.e?.id
if (!id) throw new Error(`CalendarEvent create failed: ${JSON.stringify(result?.notCreated ?? resp)}`)
return { uid: event.uid, id }
}
async deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, destroy: [eventId] }, 'd']])
const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
if ((result?.destroyed as string[] | undefined)?.includes(eventId)) return
const notDestroyed = result?.notDestroyed?.[eventId]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`CalendarEvent delete failed (id=${eventId}): ${JSON.stringify(notDestroyed)}`)
}
}
private async firstCalendarId(access: HostCalendarAccess, accountId: string): Promise<string> {
const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
const id = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? [])[0]?.id
if (!id) throw new Error('Host has no writable calendar')
return id
}
}
// Render a UTC instant as a JSCalendar floating local date-time string in `tz`
// ("YYYY-MM-DDTHH:mm:ss", no offset — the offset is carried by `timeZone`).
function toJsCalLocal(utc: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).formatToParts(utc)
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '00'
// en-CA gives hour "24" at midnight in some runtimes; normalise to "00".
const hour = get('hour') === '24' ? '00' : get('hour')
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`
}
// Milliseconds → ISO-8601 duration (whole minutes are enough for bookings).
function isoDuration(ms: number): string {
const totalMinutes = Math.round(ms / 60000)
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
return `PT${h ? `${h}H` : ''}${m || !h ? `${m}M` : ''}`
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { IntegrationsModule } from '../../integrations/integrations.module.js'
import { Host, HostSchema } from '../../schemas/scheduling-host.schema.js'
import { StalwartCredential, StalwartCredentialSchema } from '../../schemas/stalwart-credential.schema.js'
import { CredentialCipher } from '../crypto/credential-cipher.js'
import { CredentialProvisioner } from './credential-provisioner.service.js'
import { JmapCalendarGateway } from './jmap-calendar.gateway.js'
// Isolated Stalwart calendar integration — the only place that knows JMAP. Kept
// self-contained so it can be extracted to a microservice later (CLAUDE.md).
@Module({
imports: [
MongooseModule.forFeature([
{ name: StalwartCredential.name, schema: StalwartCredentialSchema },
{ name: Host.name, schema: HostSchema },
]),
IntegrationsModule, // StalwartClient (admin JMAP) for app-password provisioning
],
providers: [CredentialCipher, CredentialProvisioner, JmapCalendarGateway],
exports: [CredentialProvisioner, JmapCalendarGateway],
})
export class StalwartCalendarModule {}