feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars

First-party booking system on top of Stalwart calendars (no third-party
scheduling dependency). Hosts expose public booking pages; visitors pick a
slot computed from the host's live Stalwart free/busy, and confirming writes
the event to the host's calendar and sends a dezky-branded confirmation with
an .ics.

platform-api (services/platform-api/src/scheduling):
- Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule,
  EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL).
- StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability,
  event create/delete, scheduleAgent=client) + on-behalf app-password
  provisioning. CredentialCipher for at-rest encryption.
- DST-correct slot engine (Luxon) with unit tests; two-layer double-booking
  guard (atomic SlotLock + live free/busy re-check).
- Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission,
  self-service manage tokens. /api/v1 public + tenant-gated admin routes,
  per-IP rate limiting.

apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) —
path-based tenant resolution, per-tenant brand colour, booking + manage flows.

apps/portal: admin scheduling page (hosts, event types, availability, bookings
with edit/delete + admin cancel/reschedule) and proxy routes.

infra: booking dev service in docker-compose; scheduling env vars.
This commit is contained in:
Ronni Baslund
2026-06-07 00:17:36 +02:00
parent aee8f13899
commit 5ed3d2bc5f
62 changed files with 13633 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
// Shapes returned by the public scheduling API (mirrors platform-api's
// PublicSchedulingController response mappers).
export interface Branding {
tenantSlug: string
name: string
brandColor: string | null
}
export interface PublicInfo {
branding: Branding
host: { slug: string; displayName: string; timezone: string }
eventType: {
slug: string
title: string
description: string | null
durationMinutes: number
locationType: string
}
}
export interface SlotsResponse {
timezone: string
durationMinutes: number
slots: Array<{ startUtc: string; endUtc: string }>
}
export interface PublicBooking {
manageToken: string
status: 'pending' | 'confirmed' | 'cancelled' | 'rescheduled'
startUtc: string
endUtc: string
attendeeName: string
attendeeEmail: string
attendeeTimezone: string
attendeeNotes: string | null
locationType: string | null
locationUrl: string | null
branding: Branding
host: { slug: string; displayName: string; timezone: string }
eventType: { slug: string; title: string; durationMinutes: number }
}