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

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

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

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

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

infra: booking dev service in docker-compose; scheduling env vars.
This commit is contained in:
Ronni Baslund
2026-06-07 00:17:36 +02:00
parent aee8f13899
commit 5ed3d2bc5f
62 changed files with 13633 additions and 1 deletions
@@ -0,0 +1,18 @@
// Read-only proxy to the public slots endpoint, used by the admin reschedule
// picker so an operator rebooks against the same live free/busy a customer sees.
// The upstream is unauthenticated, so no token is forwarded.
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const query = getQuery(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
const path = `/api/v1/public/${encodeURIComponent(tenantSlug)}/${encodeURIComponent(hostSlug)}/${encodeURIComponent(eventTypeSlug)}/slots`
try {
return await $fetch(base + path, { query })
} catch (err: any) {
const status = err?.response?.status ?? 502
const raw = err?.data?.message ?? err?.response?._data?.message ?? 'Upstream error'
throw createError({ statusCode: status, statusMessage: Array.isArray(raw) ? raw.join(', ') : String(raw) })
}
})
@@ -0,0 +1,34 @@
// Catch-all proxy for the dezky Scheduling admin API. Forwards any method under
// /api/tenants/:slug/scheduling/** to platform-api's
// /api/v1/tenants/:slug/scheduling/** with the signed-in user's access token;
// platform-api enforces tenant membership. Upstream status codes are preserved
// so the admin UI sees real 400/403/404/409 responses.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const path = getRouterParam(event, 'path') ?? ''
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
const method = event.method
const query = getQuery(event)
const body = ['POST', 'PUT', 'PATCH'].includes(method) ? await readBody(event).catch(() => undefined) : undefined
try {
return await $fetch(`${base}/api/v1/tenants/${slug}/scheduling/${path}`, {
method: method as any,
query,
body,
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: any) {
const status = err?.response?.status ?? 502
const raw = err?.data?.message ?? err?.response?._data?.message ?? 'Upstream error'
throw createError({ statusCode: status, statusMessage: Array.isArray(raw) ? raw.join(', ') : String(raw) })
}
})