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
@@ -33,6 +33,7 @@ volumes:
platform_api_node_modules:
operator_node_modules:
website_node_modules:
booking_node_modules:
# MinIO data (S3-compatible cold storage for audit archives). Production
# swaps the endpoint to Hetzner Object Storage and this volume goes away.
minio_data:
@@ -570,6 +571,38 @@ services:
- traefik.http.routers.website.tls=true
- traefik.http.services.website.loadbalancer.server.port=3000
# ─────────────────────────────────────────────────────────────────
# Booking — dezky Scheduling public booking app (booking.dezky.local).
# Fully public, no OIDC: only calls platform-api's /api/v1/public/* via its
# own nitro proxy. Whitelabel-themed per tenant from the API response.
# ─────────────────────────────────────────────────────────────────
booking:
image: node:20-alpine
container_name: dezky-booking
restart: unless-stopped
working_dir: /app
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
environment:
NODE_ENV: development
NUXT_HOST: 0.0.0.0
NUXT_PORT: 3000
# Served behind Traefik TLS → Vite HMR over wss:443.
DEZKY_TRAEFIK: "1"
# How nitro reaches platform-api inside the docker network.
PLATFORM_API_INTERNAL_URL: http://platform-api:3001
volumes:
- ../../apps/booking:/app
- booking_node_modules:/app/node_modules
networks: [dezky]
depends_on:
mongo:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.booking.rule=Host(`booking.dezky.local`)
- traefik.http.routers.booking.tls=true
- traefik.http.services.booking.loadbalancer.server.port=3000
# ─────────────────────────────────────────────────────────────────
# platform-api — NestJS service. Owns tenants, partners, users,
# subscriptions, and provisioning orchestration.
@@ -647,6 +680,12 @@ services:
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_ENABLED:-false}
# dezky Scheduling. AES-256-GCM key (64 hex chars) encrypting per-host
# Stalwart app passwords at rest — never logged. Public booking app origin
# for manage links; Jitsi base for generated meeting rooms.
SCHEDULING_CREDENTIAL_KEY: ${SCHEDULING_CREDENTIAL_KEY}
BOOKING_PUBLIC_URL: ${BOOKING_PUBLIC_URL:-https://booking.dezky.local}
MEET_PUBLIC_URL: ${MEET_PUBLIC_URL:-https://meet.dezky.local}
volumes:
- ../../services/platform-api:/app
- platform_api_node_modules:/app/node_modules