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
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { PublicBooking } from '~/types/booking'
const route = useRoute()
const token = route.params.token as string
const { data: booking, error, refresh } = await useFetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(token)}`)
const cancelling = ref(false)
const cancelReason = ref('')
const showCancel = ref(false)
const actionError = ref<string | null>(null)
watchEffect(() => {
if (!import.meta.client || !booking.value) return
const accent = booking.value.branding.brandColor || '#1a1a1a'
document.documentElement.style.setProperty('--accent', accent)
})
const rescheduleUrl = computed(() =>
booking.value
? `/${booking.value.branding.tenantSlug}/${booking.value.host.slug}/${booking.value.eventType.slug}?reschedule=${booking.value.manageToken}`
: '#',
)
async function doCancel() {
cancelling.value = true
actionError.value = null
try {
booking.value = await $fetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(token)}/cancel`, {
method: 'POST',
body: { reason: cancelReason.value || undefined },
})
showCancel.value = false
} catch (e: any) {
actionError.value = e?.statusMessage || 'Could not cancel. Please try again.'
} finally {
cancelling.value = false
}
}
function statusLabel(s: string): string {
return { confirmed: 'Confirmed', cancelled: 'Cancelled', rescheduled: 'Rescheduled', pending: 'Pending' }[s] ?? s
}
useHead({ title: 'Manage booking' })
</script>
<template>
<main class="wrap">
<div v-if="error || !booking" class="card empty">
<h1>Booking not found</h1>
<p class="mute">This link may be incorrect or the booking was removed.</p>
</div>
<div v-else class="card">
<header class="head">
<div class="brand">{{ booking.branding.name }}</div>
<h1 class="title">{{ booking.eventType.title }}</h1>
<p class="meta">with {{ booking.host.displayName }}</p>
</header>
<section class="step">
<div class="status" :class="booking.status">{{ statusLabel(booking.status) }}</div>
<p class="when">{{ fmtRange(booking.startUtc, booking.endUtc, booking.attendeeTimezone) }}</p>
<p v-if="booking.locationUrl" class="loc">Location: <a :href="booking.locationUrl">{{ booking.locationUrl }}</a></p>
<template v-if="booking.status === 'confirmed'">
<div v-if="!showCancel" class="actions">
<NuxtLink class="bk-btn bk-btn--primary" :to="rescheduleUrl">Reschedule</NuxtLink>
<button class="bk-btn bk-btn--ghost" @click="showCancel = true">Cancel booking</button>
</div>
<div v-else class="cancelbox">
<div class="bk-field">
<label class="bk-label" for="reason">Reason (optional)</label>
<input id="reason" v-model="cancelReason" class="bk-input" maxlength="500" />
</div>
<p v-if="actionError" class="err">{{ actionError }}</p>
<div class="actions">
<button class="bk-btn bk-btn--ghost" @click="showCancel = false" :disabled="cancelling">Keep it</button>
<button class="bk-btn bk-btn--primary danger" @click="doCancel" :disabled="cancelling">
{{ cancelling ? 'Cancelling…' : 'Confirm cancellation' }}
</button>
</div>
</div>
</template>
<p v-else-if="booking.status === 'cancelled'" class="mute">This booking has been cancelled.</p>
<p v-else-if="booking.status === 'rescheduled'" class="mute">This booking was rescheduled to a new time.</p>
</section>
<p class="powered">Powered by {{ booking.branding.name }}</p>
</div>
</main>
</template>
<style scoped>
.wrap { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; }
.card { width: 100%; max-width: 520px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card::before { content: ''; display: block; height: 6px; background: var(--accent); }
.head { padding: 28px 28px 0; }
.brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-mute); }
.title { font-size: 22px; margin: 6px 0 4px; }
.meta { color: var(--text-mute); margin: 0; font-size: 14px; }
.step { padding: 22px 28px 28px; }
.status { display: inline-block; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 4px 10px; border-radius: 999px; background: #eee; color: #444; }
.status.confirmed { background: #e6f6ec; color: #1d7a44; }
.status.cancelled { background: #fdeceb; color: #c0362c; }
.status.rescheduled { background: #fef6e6; color: #9a6a00; }
.when { font-weight: 600; margin: 14px 0 4px; }
.loc { font-size: 14px; }
.actions { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
.cancelbox { margin-top: 20px; display: flex; flex-direction: column; gap: 14px; }
.danger { background: #c0362c; }
.mute { color: var(--text-mute); margin-top: 16px; }
.err { color: #c0362c; font-size: 14px; }
.empty { padding: 40px; text-align: center; }
.powered { text-align: center; color: #aaa; font-size: 12px; padding: 0 0 20px; }
</style>