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,32 @@
|
||||
// Jest (ESM + TypeScript via ts-jest). The codebase is NodeNext ESM with `.js`
|
||||
// import specifiers, so we strip the extension in moduleNameMapper to resolve
|
||||
// `./x.js` → `./x.ts` under jest. Run with NODE_OPTIONS=--experimental-vm-modules
|
||||
// (set in the package.json "test" script).
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/?(*.)+(spec).ts'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.ts$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: {
|
||||
// Emit ESM regardless of package.json "type" (NodeNext would emit CJS
|
||||
// here and break under jest's ESM VM → "exports is not defined").
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
isolatedModules: true,
|
||||
experimentalDecorators: true,
|
||||
emitDecoratorMetadata: true,
|
||||
verbatimModuleSyntax: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,9 +19,12 @@
|
||||
"@nestjs/platform-fastify": "^10.4.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/mongoose": "^10.1.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ical-generator": "^8.0.1",
|
||||
"jose": "^5.9.0",
|
||||
"luxon": "^3.5.0",
|
||||
"mongoose": "^8.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
@@ -30,7 +33,11 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/testing": "^10.4.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.5.0",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
|
||||
Generated
+2129
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { MailModule } from './mail/mail.module.js'
|
||||
import { MeModule } from './me/me.module.js'
|
||||
import { PartnersModule } from './partners/partners.module.js'
|
||||
import { PricesModule } from './prices/prices.module.js'
|
||||
import { SchedulingModule } from './scheduling/scheduling.module.js'
|
||||
import { SeedModule } from './seed/seed.module.js'
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
|
||||
import { TenantsModule } from './tenants/tenants.module.js'
|
||||
@@ -37,6 +38,7 @@ import { UsersModule } from './users/users.module.js'
|
||||
FlagsModule,
|
||||
BillingModule,
|
||||
IngestModule,
|
||||
SchedulingModule,
|
||||
SeedModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -269,6 +269,50 @@ export class StalwartClient {
|
||||
}
|
||||
}
|
||||
|
||||
// The Stalwart account id whose primary address matches `email`, or undefined.
|
||||
// Stalwart's account query has no email filter, so we list + match in memory.
|
||||
async findAccountIdByEmail(email: string): Promise<string | undefined> {
|
||||
const target = email.trim().toLowerCase()
|
||||
const accounts = await this.listAccountsWithAliases()
|
||||
return accounts.find((a) => a.emailAddress?.toLowerCase() === target)?.id
|
||||
}
|
||||
|
||||
// ── App passwords (per-account credentials for on-behalf calendar access) ────
|
||||
// Used by dezky Scheduling to obtain a scoped credential for a host without a
|
||||
// user-facing "connect calendar" step. Admin mints on-behalf by passing the
|
||||
// target account id; the returned `secret` is shown once and must be stored
|
||||
// encrypted by the caller (it is never logged here).
|
||||
|
||||
async createAppPassword(accountId: string, description: string): Promise<{ id: string; secret: string }> {
|
||||
const resp = await this.jmap([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{
|
||||
accountId,
|
||||
create: { ap: { description, permissions: { '@type': 'Inherit' }, allowedIps: {} } },
|
||||
},
|
||||
'0',
|
||||
],
|
||||
])
|
||||
const created = resp[0][1].created?.ap
|
||||
if (!created?.id || !created?.secret) {
|
||||
throw new Error(
|
||||
`Stalwart app-password create failed for account ${accountId}: ${JSON.stringify(resp[0][1].notCreated)}`,
|
||||
)
|
||||
}
|
||||
return { id: created.id, secret: created.secret }
|
||||
}
|
||||
|
||||
async deleteAppPassword(accountId: string, id: string): Promise<void> {
|
||||
const resp = await this.jmap([['x:AppPassword/set', { accountId, destroy: [id] }, '0']])
|
||||
const result = resp[0][1]
|
||||
if ((result.destroyed as string[] | undefined)?.includes(id)) return
|
||||
const notDestroyed = result.notDestroyed?.[id]
|
||||
if (notDestroyed && notDestroyed.type !== 'notFound') {
|
||||
throw new Error(`Stalwart app-password delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
|
||||
|
||||
// Every mailbox + its aliases. Stalwart's account query has no domain filter,
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:00–17: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:00–17: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:00–11: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:00–10:30 local (08:00–08: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:00–10: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:00–03: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), // 09–17 normally
|
||||
dateOverrides: [{ date: '2026-06-01', isUnavailable: false, intervals: [{ startMinute: 780, endMinute: 840 }] }], // 13:00–14: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>
|
||||
}
|
||||
+102
@@ -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 {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type AvailabilityScheduleDocument = HydratedDocument<AvailabilitySchedule>
|
||||
|
||||
// An interval within a day, in MINUTES from local midnight in the schedule's tz.
|
||||
// e.g. 09:00–17:00 => { startMinute: 540, endMinute: 1020 }.
|
||||
export interface MinuteInterval {
|
||||
startMinute: number
|
||||
endMinute: number
|
||||
}
|
||||
|
||||
export interface WeeklyRule {
|
||||
dayOfWeek: number // 0=Sun .. 6=Sat
|
||||
intervals: MinuteInterval[]
|
||||
}
|
||||
|
||||
export interface DateOverride {
|
||||
date: string // "YYYY-MM-DD" in the schedule tz
|
||||
isUnavailable: boolean // true => whole day blocked (intervals ignored)
|
||||
intervals: MinuteInterval[] // replace the weekly intervals for this date
|
||||
}
|
||||
|
||||
// Recurring weekly availability + specific-date overrides, authored in `timezone`.
|
||||
// Slot computation converts these local windows to UTC via Luxon (DST-correct).
|
||||
@Schema({ collection: 'scheduling_availability_schedules', timestamps: true })
|
||||
export class AvailabilitySchedule {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
|
||||
hostId!: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
name!: string
|
||||
|
||||
@Prop({ required: true })
|
||||
timezone!: string
|
||||
|
||||
@Prop({
|
||||
type: [
|
||||
{
|
||||
dayOfWeek: { type: Number, min: 0, max: 6, required: true },
|
||||
intervals: [{ startMinute: Number, endMinute: Number }],
|
||||
},
|
||||
],
|
||||
default: [],
|
||||
})
|
||||
weeklyRules!: WeeklyRule[]
|
||||
|
||||
@Prop({
|
||||
type: [
|
||||
{
|
||||
date: { type: String, required: true },
|
||||
isUnavailable: { type: Boolean, default: false },
|
||||
intervals: [{ startMinute: Number, endMinute: Number }],
|
||||
},
|
||||
],
|
||||
default: [],
|
||||
})
|
||||
dateOverrides!: DateOverride[]
|
||||
}
|
||||
|
||||
export const AvailabilityScheduleSchema = SchemaFactory.createForClass(AvailabilitySchedule)
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
import type { LocationType } from './event-type.schema.js'
|
||||
|
||||
export type BookingDocument = HydratedDocument<Booking>
|
||||
|
||||
// 'pending' is the compensating state when the SlotLock is held but the calendar
|
||||
// write hasn't succeeded yet (§8.2.4) — never surfaced as a confirmed booking.
|
||||
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'rescheduled'
|
||||
|
||||
// A confirmed appointment. All instants are UTC; attendee/host tz are IANA
|
||||
// strings for display. `calendarEventUid` is generated client-side BEFORE the
|
||||
// Stalwart write so a retried write is idempotent (§9). `manageToken` backs the
|
||||
// unauthenticated self-service cancel/reschedule links.
|
||||
@Schema({ collection: 'scheduling_bookings', timestamps: true })
|
||||
export class Booking {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'EventType', required: true, index: true })
|
||||
eventTypeId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
|
||||
hostId!: Types.ObjectId
|
||||
|
||||
@Prop({ enum: ['pending', 'confirmed', 'cancelled', 'rescheduled'], default: 'pending', index: true })
|
||||
status!: BookingStatus
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
startUtc!: Date
|
||||
|
||||
@Prop({ required: true })
|
||||
endUtc!: Date
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
attendeeName!: string
|
||||
|
||||
@Prop({ required: true, lowercase: true, trim: true })
|
||||
attendeeEmail!: string
|
||||
|
||||
@Prop({ required: true })
|
||||
attendeeTimezone!: string
|
||||
|
||||
@Prop({ trim: true })
|
||||
attendeeNotes?: string
|
||||
|
||||
// UID written to Stalwart — generated client-side for idempotent retries.
|
||||
@Prop({ required: true, index: true })
|
||||
calendarEventUid!: string
|
||||
|
||||
// Server-assigned CalendarEvent id (Stalwart's own id, distinct from the UID).
|
||||
// Needed for delete/update, which address events by server id, not UID.
|
||||
@Prop()
|
||||
calendarEventId?: string
|
||||
|
||||
@Prop({ enum: ['jitsi', 'phone', 'in_person', 'custom'] })
|
||||
locationType?: LocationType
|
||||
|
||||
// Resolved location (e.g. generated Jitsi room url, or copied details).
|
||||
@Prop({ trim: true })
|
||||
locationUrl?: string
|
||||
|
||||
// Opaque token for the self-service manage page. Unique + indexed for lookup.
|
||||
@Prop({ required: true, unique: true, index: true })
|
||||
manageToken!: string
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Booking' })
|
||||
rescheduledFromBookingId?: Types.ObjectId
|
||||
|
||||
@Prop()
|
||||
cancelledAt?: Date
|
||||
|
||||
@Prop({ trim: true })
|
||||
cancellationReason?: string
|
||||
|
||||
// Reminder bookkeeping for Phase 2 (e.g. 'none' | 'sent_24h').
|
||||
@Prop({ default: 'none' })
|
||||
reminderState!: string
|
||||
}
|
||||
|
||||
export const BookingSchema = SchemaFactory.createForClass(Booking)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type EventTypeDocument = HydratedDocument<EventType>
|
||||
|
||||
export type LocationType = 'jitsi' | 'phone' | 'in_person' | 'custom'
|
||||
|
||||
// A bookable appointment type for a host (e.g. "30-min consultation"). Carries
|
||||
// the booking rules slot computation needs: duration, granularity, buffers,
|
||||
// notice window, and horizon.
|
||||
@Schema({ collection: 'scheduling_event_types', timestamps: true })
|
||||
export class EventType {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
|
||||
hostId!: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, lowercase: true, trim: true })
|
||||
slug!: string
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
title!: string
|
||||
|
||||
@Prop({ trim: true })
|
||||
description?: string
|
||||
|
||||
@Prop({ required: true, min: 1 })
|
||||
durationMinutes!: number
|
||||
|
||||
// Granularity of offered start times (e.g. 15 => :00/:15/:30/:45).
|
||||
@Prop({ required: true, min: 1, default: 15 })
|
||||
slotIntervalMinutes!: number
|
||||
|
||||
@Prop({ min: 0, default: 0 })
|
||||
bufferBeforeMinutes!: number
|
||||
|
||||
@Prop({ min: 0, default: 0 })
|
||||
bufferAfterMinutes!: number
|
||||
|
||||
// Earliest bookable time relative to now.
|
||||
@Prop({ min: 0, default: 0 })
|
||||
minimumNoticeMinutes!: number
|
||||
|
||||
// Booking horizon — how far ahead slots are offered.
|
||||
@Prop({ min: 1, default: 60 })
|
||||
maximumDaysInFuture!: number
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'AvailabilitySchedule', required: true })
|
||||
availabilityScheduleId!: Types.ObjectId
|
||||
|
||||
@Prop({ enum: ['jitsi', 'phone', 'in_person', 'custom'], default: 'jitsi' })
|
||||
locationType!: LocationType
|
||||
|
||||
// Free text for phone / in_person / custom (e.g. a phone number or address).
|
||||
@Prop({ trim: true })
|
||||
locationDetails?: string
|
||||
|
||||
@Prop({ trim: true })
|
||||
color?: string
|
||||
|
||||
@Prop({ default: true, index: true })
|
||||
isActive!: boolean
|
||||
}
|
||||
|
||||
export const EventTypeSchema = SchemaFactory.createForClass(EventType)
|
||||
EventTypeSchema.index({ tenantId: 1, hostId: 1, slug: 1 }, { unique: true })
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type HostDocument = HydratedDocument<Host>
|
||||
|
||||
// A bookable user. One Host per workspace user who exposes public booking pages.
|
||||
// A User becomes a Host only when made bookable (so most users never get a row).
|
||||
// `email` matches the Stalwart account; `stalwartAccountId` is the management
|
||||
// account id (x:Account) used to provision the calendar credential and resolve
|
||||
// the calendar principal. Kept as a distinct collection from User so scheduling
|
||||
// stays extractable to a microservice later (see CLAUDE.md §"Hosting").
|
||||
@Schema({ collection: 'scheduling_hosts', timestamps: true })
|
||||
export class Host {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
// OIDC subject (Authentik `sub`) of the underlying workspace user.
|
||||
@Prop({ required: true, index: true })
|
||||
authentikUserId!: string
|
||||
|
||||
@Prop({ required: true, lowercase: true, trim: true })
|
||||
email!: string
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
displayName!: string
|
||||
|
||||
// URL segment in the public booking page: booking.dezky.eu/:tenantSlug/:slug/...
|
||||
@Prop({ required: true, lowercase: true, trim: true })
|
||||
slug!: string
|
||||
|
||||
// IANA tz the host's availability is authored/displayed in, e.g. "Europe/Copenhagen".
|
||||
@Prop({ required: true })
|
||||
timezone!: string
|
||||
|
||||
// Stalwart management account id (x:Account) — used for on-behalf credential
|
||||
// provisioning and to locate the calendar principal.
|
||||
@Prop({ required: true })
|
||||
stalwartAccountId!: string
|
||||
|
||||
// Calendar the confirmed bookings are written to.
|
||||
@Prop()
|
||||
defaultCalendarId?: string
|
||||
|
||||
// Calendars consulted for free/busy. Defaults to [defaultCalendarId] at read time.
|
||||
@Prop({ type: [String], default: [] })
|
||||
busyCalendarIds!: string[]
|
||||
|
||||
@Prop({ default: true, index: true })
|
||||
isActive!: boolean
|
||||
}
|
||||
|
||||
export const HostSchema = SchemaFactory.createForClass(Host)
|
||||
// Public URL uniqueness + fast lookup; one Host row per workspace user.
|
||||
HostSchema.index({ tenantId: 1, slug: 1 }, { unique: true })
|
||||
HostSchema.index({ tenantId: 1, authentikUserId: 1 }, { unique: true })
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type SlotLockDocument = HydratedDocument<SlotLock>
|
||||
|
||||
// Atomic double-booking guard (§8.2 layer 1). The unique (hostId, startUtc) index
|
||||
// is the hard guarantee: two concurrent confirmations for the same start can't
|
||||
// both insert — the loser gets a duplicate-key error and a clean "slot taken".
|
||||
//
|
||||
// Lifecycle:
|
||||
// - hold during checkout: expiresAt = now + N min (TTL reaps abandoned holds).
|
||||
// - confirmed: bookingId set, expiresAt = null. MongoDB's TTL index only deletes
|
||||
// docs whose indexed field is a Date, so a null expiresAt makes the lock
|
||||
// permanent — keeping the slot blocked for the life of the booking.
|
||||
@Schema({ collection: 'scheduling_slot_locks', timestamps: true })
|
||||
export class SlotLock {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Host', required: true })
|
||||
hostId!: Types.ObjectId
|
||||
|
||||
@Prop({ required: true })
|
||||
startUtc!: Date
|
||||
|
||||
@Prop({ required: true })
|
||||
endUtc!: Date
|
||||
|
||||
// TTL anchor for un-confirmed holds; null once confirmed (permanent).
|
||||
@Prop({ type: Date, default: null })
|
||||
expiresAt!: Date | null
|
||||
|
||||
// Opaque token returned by POST /holds. A confirm may only claim an existing
|
||||
// hold by presenting its token — so a confirm without the token falls through
|
||||
// to a fresh unique insert (dup-key => "slot taken") and can never hijack
|
||||
// someone else's active hold.
|
||||
@Prop({ type: String, default: null })
|
||||
holdToken!: string | null
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Booking', default: null })
|
||||
bookingId!: Types.ObjectId | null
|
||||
}
|
||||
|
||||
export const SlotLockSchema = SchemaFactory.createForClass(SlotLock)
|
||||
// Hard uniqueness: one lock per (host, start).
|
||||
SlotLockSchema.index({ hostId: 1, startUtc: 1 }, { unique: true })
|
||||
// TTL: reap abandoned holds. expireAfterSeconds:0 => delete when expiresAt passes;
|
||||
// docs with expiresAt=null (confirmed) are never reaped.
|
||||
SlotLockSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type StalwartCredentialDocument = HydratedDocument<StalwartCredential>
|
||||
|
||||
export type StalwartCredentialType = 'app_password' | 'oauth'
|
||||
|
||||
// Per-host Stalwart credential, encrypted at rest (AES-256-GCM via
|
||||
// CredentialCipher). One row per host. The secret is an app password minted
|
||||
// on-behalf through the Stalwart admin JMAP (x:AppPassword/set {accountId}); see
|
||||
// reference: Phase 0 spike. The plaintext secret is NEVER stored or logged —
|
||||
// only the ciphertext + iv + authTag triplet.
|
||||
@Schema({ collection: 'scheduling_stalwart_credentials', timestamps: true })
|
||||
export class StalwartCredential {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||
tenantId!: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, unique: true, index: true })
|
||||
hostId!: Types.ObjectId
|
||||
|
||||
@Prop({ enum: ['app_password', 'oauth'], default: 'app_password' })
|
||||
type!: StalwartCredentialType
|
||||
|
||||
@Prop({ required: true })
|
||||
encryptedSecret!: string
|
||||
|
||||
@Prop({ required: true })
|
||||
iv!: string
|
||||
|
||||
@Prop({ required: true })
|
||||
authTag!: string
|
||||
|
||||
// Stalwart AppPassword object id — kept so we can rotate/destroy it later.
|
||||
@Prop()
|
||||
appPasswordId?: string
|
||||
|
||||
// JMAP session endpoint (.well-known/jmap) the secret authenticates against.
|
||||
@Prop({ required: true })
|
||||
jmapSessionUrl!: string
|
||||
|
||||
// CalDAV base URL — retained for the (currently shelved) fallback transport.
|
||||
@Prop()
|
||||
caldavBaseUrl?: string
|
||||
|
||||
@Prop()
|
||||
lastValidatedAt?: Date
|
||||
}
|
||||
|
||||
export const StalwartCredentialSchema = SchemaFactory.createForClass(StalwartCredential)
|
||||
Reference in New Issue
Block a user