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
+67
View File
@@ -0,0 +1,67 @@
import { DateTime } from 'luxon'
// Visitor timezone detection (browser); falls back to UTC on the server / when
// unavailable. All slot times are transmitted as UTC and rendered in this zone.
export function detectTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
// UTC [from, to) covering one local calendar day in `tz`.
export function dayWindowUtc(isoDate: string, tz: string): { from: string; to: string } {
const start = DateTime.fromISO(isoDate, { zone: tz }).startOf('day')
return { from: start.toUTC().toISO()!, to: start.plus({ days: 1 }).toUTC().toISO()! }
}
export interface DayOption {
iso: string // yyyy-MM-dd in tz
weekday: string
day: string
month: string
}
// The next `count` calendar days starting today, in the visitor's tz.
export function nextDays(tz: string, count: number): DayOption[] {
const today = DateTime.now().setZone(tz).startOf('day')
return Array.from({ length: count }, (_, i) => {
const d = today.plus({ days: i })
return { iso: d.toFormat('yyyy-MM-dd'), weekday: d.toFormat('ccc'), day: d.toFormat('d'), month: d.toFormat('LLL') }
})
}
export function fmtTime(utc: string, tz: string): string {
return DateTime.fromISO(utc).setZone(tz).toFormat('HH:mm')
}
export function fmtDateLong(utc: string, tz: string): string {
return DateTime.fromISO(utc).setZone(tz).toFormat('cccc d LLLL yyyy')
}
export function fmtRange(startUtc: string, endUtc: string, tz: string): string {
const s = DateTime.fromISO(startUtc).setZone(tz)
const e = DateTime.fromISO(endUtc).setZone(tz)
return `${s.toFormat('cccc d LLLL')} · ${s.toFormat('HH:mm')}${e.toFormat('HH:mm')} (${tz})`
}
// Google Calendar "add to calendar" template link (UTC basic format).
export function googleCalUrl(p: { title: string; startUtc: string; endUtc: string; details?: string; location?: string }): string {
const fmt = (iso: string) => DateTime.fromISO(iso).toUTC().toFormat("yyyyLLdd'T'HHmmss'Z'")
const params = new URLSearchParams({
action: 'TEMPLATE',
text: p.title,
dates: `${fmt(p.startUtc)}/${fmt(p.endUtc)}`,
})
if (p.details) params.set('details', p.details)
if (p.location) params.set('location', p.location)
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
// Common IANA zones for the manual switcher (visitor tz is added if missing).
export const COMMON_TIMEZONES = [
'Europe/Copenhagen', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Madrid',
'Europe/Stockholm', 'Europe/Helsinki', 'America/New_York', 'America/Chicago', 'America/Los_Angeles',
'Asia/Dubai', 'Asia/Kolkata', 'Asia/Singapore', 'Asia/Tokyo', 'Australia/Sydney', 'UTC',
]