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
+6
View File
@@ -0,0 +1,6 @@
node_modules
.nuxt
.output
.git
dist
*.log
+7
View File
@@ -0,0 +1,7 @@
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles.
package-lock.json
node_modules
.nuxt
.output
.data
*.log
+22
View File
@@ -0,0 +1,22 @@
# Production image for the dezky booking app (Nuxt 4 SSR).
# Build context = this directory (apps/booking).
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NUXT_PUBLIC_SITE_URL=https://booking.dezky.eu
# Set PLATFORM_API_INTERNAL_URL at deploy time to reach platform-api.
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+48
View File
@@ -0,0 +1,48 @@
/* Booking app base styles + design tokens. The accent is whitelabel: each
booking page sets --accent from the tenant's brandColor at runtime (see the
booking page's :style binding); this file only provides the fallback. */
:root {
--accent: #1a1a1a;
--accent-contrast: #ffffff;
--bg: #f6f6f7;
--surface: #ffffff;
--text: #1a1a1a;
--text-mute: #6b6b70;
--border: #e7e7ea;
--radius: 14px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
a { color: inherit; }
button { font-family: inherit; }
/* Shared primitives used across pages */
.bk-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 44px; padding: 0 20px; border-radius: 10px; border: 1px solid transparent;
font-size: 15px; font-weight: 600; cursor: pointer; transition: filter .15s, opacity .15s;
}
.bk-btn--primary { background: var(--accent); color: var(--accent-contrast); }
.bk-btn--primary:hover { filter: brightness(1.06); }
.bk-btn--ghost { background: transparent; border-color: var(--border); color: var(--text); }
.bk-btn--ghost:hover { background: #00000008; }
.bk-btn:disabled { opacity: .5; cursor: not-allowed; }
.bk-field { display: flex; flex-direction: column; gap: 6px; }
.bk-label { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-mute); }
.bk-input {
height: 44px; padding: 0 14px; border: 1px solid var(--border); border-radius: 10px;
background: var(--surface); font-size: 15px; color: var(--text); width: 100%;
}
.bk-input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
textarea.bk-input { height: auto; padding: 12px 14px; resize: vertical; min-height: 84px; }
+41
View File
@@ -0,0 +1,41 @@
// Nuxt 4 config for the dezky public booking app (booking.dezky.eu).
//
// Fully public — NO OIDC, no sessions. It only ever calls the unauthenticated
// /api/v1/public/* endpoints on platform-api, proxied through this app's own
// nitro server routes so the internal API hostname never reaches the browser.
// Whitelabel: every page renders the tenant's branding (name + brandColor),
// fetched per request — there is no dezky-fixed accent.
export default defineNuxtConfig({
compatibilityDate: '2026-01-01',
devtools: { enabled: true },
css: ['~/assets/styles/base.css'],
runtimeConfig: {
// Server-only: how nitro reaches platform-api inside the docker network.
platformApiUrl: process.env.PLATFORM_API_INTERNAL_URL || 'http://platform-api:3001',
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
|| (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'),
},
},
app: {
head: {
htmlAttrs: { lang: 'en' },
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'robots', content: 'noindex' }, // booking pages aren't for search indexing
],
},
},
vite: {
server: {
allowedHosts: ['booking.dezky.local'],
hmr: process.env.DEZKY_TRAEFIK === '1' ? { protocol: 'wss', clientPort: 443 } : undefined,
},
},
})
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@dezky/booking",
"version": "0.0.1",
"private": true,
"description": "dezky Scheduling — public booking app (booking.dezky.eu). Unauthenticated, whitelabel per tenant.",
"scripts": {
"dev": "TMPDIR=/tmp nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build",
"start": "node .output/server/index.mjs",
"generate": "nuxt generate",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"luxon": "^3.5.0",
"nuxt": "^4.4.7",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^20.0.0",
"typescript": "^5.6.0",
"vue-tsc": "^3.2.6"
},
"packageManager": "pnpm@9.12.0"
}
@@ -0,0 +1,285 @@
<script setup lang="ts">
import type { PublicBooking, PublicInfo, SlotsResponse } from '~/types/booking'
const route = useRoute()
const tenantSlug = route.params.tenantSlug as string
const hostSlug = route.params.hostSlug as string
const eventTypeSlug = route.params.eventTypeSlug as string
const apiBase = `/api/public/${encodeURIComponent(tenantSlug)}/${encodeURIComponent(hostSlug)}/${encodeURIComponent(eventTypeSlug)}`
// Reschedule mode: the manage page links here with ?reschedule=<manageToken>.
// In this mode confirming moves the existing booking to the chosen slot.
const rescheduleToken = (route.query.reschedule as string | undefined) || undefined
// Event-type + host + branding (SSR).
const { data: info, error } = await useFetch<PublicInfo>(apiBase)
const visitorTz = ref('UTC')
const days = computed(() => nextDays(visitorTz.value, 14))
const selectedDate = ref<string | null>(null)
const slots = ref<SlotsResponse['slots']>([])
const slotsState = ref<'idle' | 'loading' | 'ready' | 'retry' | 'error'>('idle')
type Step = 'pick' | 'details' | 'done'
const step = ref<Step>('pick')
const selectedSlot = ref<{ startUtc: string; endUtc: string } | null>(null)
const form = reactive({ name: '', email: '', notes: '' })
const submitting = ref(false)
const submitError = ref<string | null>(null)
const booking = ref<PublicBooking | null>(null)
// Whitelabel theming — drive the accent from the tenant's brandColor.
watchEffect(() => {
if (!import.meta.client || !info.value) return
const accent = info.value.branding.brandColor || '#1a1a1a'
const el = document.documentElement
el.style.setProperty('--accent', accent)
el.style.setProperty('--accent-contrast', readableOn(accent))
})
onMounted(() => {
visitorTz.value = detectTimezone()
if (!selectedDate.value) selectedDate.value = days.value[0]?.iso ?? null
void loadSlots()
})
watch([selectedDate, visitorTz], () => loadSlots())
async function loadSlots() {
if (!selectedDate.value) return
slotsState.value = 'loading'
try {
const w = dayWindowUtc(selectedDate.value, visitorTz.value)
const res = await $fetch<SlotsResponse>(`${apiBase}/slots`, {
query: { from: w.from, to: w.to, timezone: visitorTz.value },
})
slots.value = res.slots
slotsState.value = 'ready'
} catch (e: any) {
slotsState.value = e?.statusCode === 503 ? 'retry' : 'error'
slots.value = []
}
}
function pickSlot(s: { startUtc: string; endUtc: string }) {
selectedSlot.value = s
submitError.value = null
// Reschedule carries the attendee over — no details form needed.
if (rescheduleToken) void confirm()
else step.value = 'details'
}
async function confirm() {
if (!selectedSlot.value) return
submitting.value = true
submitError.value = null
try {
if (rescheduleToken) {
booking.value = await $fetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(rescheduleToken)}/reschedule`, {
method: 'POST',
body: { startUtc: selectedSlot.value.startUtc },
})
} else {
booking.value = await $fetch<PublicBooking>(`${apiBase}/bookings`, {
method: 'POST',
body: {
startUtc: selectedSlot.value.startUtc,
attendeeName: form.name,
attendeeEmail: form.email,
attendeeTimezone: visitorTz.value,
attendeeNotes: form.notes || undefined,
},
})
}
step.value = 'done'
} catch (e: any) {
if (e?.statusCode === 409) {
submitError.value = 'That time was just taken. Please pick another slot.'
step.value = 'pick'
await loadSlots()
} else if (e?.statusCode === 503) {
submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.'
} else {
submitError.value = e?.statusMessage || 'Something went wrong. Please try again.'
}
} finally {
submitting.value = false
}
}
const addToCalUrl = computed(() =>
booking.value
? googleCalUrl({
title: `${booking.value.eventType.title} with ${booking.value.host.displayName}`,
startUtc: booking.value.startUtc,
endUtc: booking.value.endUtc,
location: booking.value.locationUrl ?? undefined,
})
: '#',
)
const manageUrl = computed(() => (booking.value ? `/manage/${booking.value.manageToken}` : '#'))
// Timezone switcher options — ensure the visitor's own zone is present.
const tzOptions = computed(() =>
COMMON_TIMEZONES.includes(visitorTz.value) ? COMMON_TIMEZONES : [visitorTz.value, ...COMMON_TIMEZONES],
)
// User-facing location label — "jitsi" is the technical enum; show "dezky Meet".
function locationLabel(t: string): string {
return (({ jitsi: 'dezky Meet', phone: 'Phone', in_person: 'In person', custom: 'Custom' }) as Record<string, string>)[t] ?? t
}
function readableOn(hex: string): string {
const c = hex.replace('#', '')
if (c.length < 6) return '#ffffff'
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#1a1a1a' : '#ffffff'
}
useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.value.branding.name}` : 'Book a time' }))
</script>
<template>
<main class="wrap">
<div v-if="error || !info" class="card empty">
<h1>Booking page not found</h1>
<p class="mute">This link may be incorrect or no longer active.</p>
</div>
<div v-else class="card">
<!-- Header -->
<header class="head">
<div class="brand">{{ info.branding.name }}</div>
<h1 class="title">{{ info.eventType.title }}</h1>
<p class="meta">
{{ info.host.displayName }} · {{ info.eventType.durationMinutes }} min
<span v-if="info.eventType.locationType"> · {{ locationLabel(info.eventType.locationType) }}</span>
</p>
<p v-if="info.eventType.description" class="desc">{{ info.eventType.description }}</p>
</header>
<!-- Step: pick -->
<section v-if="step === 'pick'" class="step">
<div v-if="rescheduleToken" class="rebanner">Choose a new time your details carry over.</div>
<div class="tzrow">
<label class="bk-label" for="tz">Times shown in</label>
<select id="tz" v-model="visitorTz" class="bk-input tz">
<option v-for="tz in tzOptions" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<div class="days">
<button
v-for="d in days"
:key="d.iso"
class="day"
:class="{ active: d.iso === selectedDate }"
@click="selectedDate = d.iso"
>
<span class="dow">{{ d.weekday }}</span>
<span class="dnum">{{ d.day }}</span>
<span class="dmon">{{ d.month }}</span>
</button>
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<div class="slots">
<p v-if="slotsState === 'loading'" class="mute">Loading times</p>
<p v-else-if="slotsState === 'retry'" class="err">
Couldnt verify availability. <button class="link" @click="loadSlots">Retry</button>
</p>
<p v-else-if="slotsState === 'error'" class="err">Couldnt load times. <button class="link" @click="loadSlots">Retry</button></p>
<p v-else-if="!slots.length" class="mute">No times available on this day.</p>
<div v-else class="slotgrid">
<button v-for="s in slots" :key="s.startUtc" class="slot" @click="pickSlot(s)">
{{ fmtTime(s.startUtc, visitorTz) }}
</button>
</div>
</div>
</section>
<!-- Step: details -->
<section v-else-if="step === 'details'" class="step">
<button class="back" @click="step = 'pick'"> Back</button>
<p class="chosen">{{ selectedSlot ? fmtDateLong(selectedSlot.startUtc, visitorTz) : '' }} ·
{{ selectedSlot ? fmtTime(selectedSlot.startUtc, visitorTz) : '' }} ({{ visitorTz }})</p>
<form class="form" @submit.prevent="confirm">
<div class="bk-field">
<label class="bk-label" for="name">Your name</label>
<input id="name" v-model="form.name" class="bk-input" required maxlength="120" />
</div>
<div class="bk-field">
<label class="bk-label" for="email">Email</label>
<input id="email" v-model="form.email" type="email" class="bk-input" required maxlength="254" />
</div>
<div class="bk-field">
<label class="bk-label" for="notes">Notes (optional)</label>
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<button type="submit" class="bk-btn bk-btn--primary" :disabled="submitting">
{{ submitting ? 'Confirming…' : 'Confirm booking' }}
</button>
</form>
</section>
<!-- Step: done -->
<section v-else class="step done">
<div class="tick" aria-hidden="true"></div>
<h2>Youre booked</h2>
<p class="chosen" v-if="booking">{{ fmtRange(booking.startUtc, booking.endUtc, booking.attendeeTimezone) }}</p>
<p class="mute">A confirmation with a calendar invite has been sent to {{ booking?.attendeeEmail }}.</p>
<p v-if="booking?.locationUrl" class="loc">Location: <a :href="booking.locationUrl">{{ booking.locationUrl }}</a></p>
<div class="actions">
<a class="bk-btn bk-btn--primary" :href="addToCalUrl" target="_blank" rel="noopener">Add to Google Calendar</a>
<NuxtLink class="bk-btn bk-btn--ghost" :to="manageUrl">Manage booking</NuxtLink>
</div>
</section>
</div>
<p class="powered" v-if="info">Powered by {{ info.branding.name }}</p>
</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: 560px; 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: 24px; margin: 6px 0 4px; }
.meta { color: var(--text-mute); margin: 0; font-size: 14px; }
.desc { margin: 14px 0 0; color: var(--text); font-size: 15px; }
.step { padding: 22px 28px 28px; }
.rebanner { background: #fef6e6; color: #9a6a00; font-size: 13px; padding: 8px 12px; border-radius: 8px; margin-bottom: 14px; }
.tzrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.tz { width: auto; height: 38px; flex: 1; }
.days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.day { flex: 0 0 auto; width: 64px; padding: 10px 0; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.day.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.dow { font-size: 11px; text-transform: uppercase; color: var(--text-mute); }
.dnum { font-size: 18px; font-weight: 600; }
.dmon { font-size: 11px; color: var(--text-mute); }
.slots { margin-top: 18px; min-height: 80px; }
.slotgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
.slot { height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); font-size: 15px; font-weight: 600; cursor: pointer; }
.slot:hover { border-color: var(--accent); }
.form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; }
.chosen { font-weight: 600; margin: 0 0 6px; }
.back { background: none; border: none; color: var(--text-mute); cursor: pointer; padding: 0 0 12px; font-size: 14px; }
.done { text-align: center; }
.tick { width: 56px; height: 56px; border-radius: 50%; background: var(--accent); color: var(--accent-contrast); font-size: 28px; display: flex; align-items: center; justify-content: center; margin: 4px auto 12px; }
.actions { display: flex; gap: 10px; justify-content: center; margin-top: 20px; flex-wrap: wrap; }
.loc { font-size: 14px; }
.mute { color: var(--text-mute); }
.err { color: #c0362c; font-size: 14px; }
.link { background: none; border: none; color: var(--accent); cursor: pointer; text-decoration: underline; padding: 0; font: inherit; }
.empty { padding: 40px; text-align: center; }
.powered { margin-top: 16px; color: #aaa; font-size: 12px; }
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
// Root of booking.dezky.eu carries no tenant context — booking links are always
// /:tenantSlug/:hostSlug/:eventTypeSlug. Show a neutral placeholder.
useHead({ title: 'dezky Scheduling' })
</script>
<template>
<main class="wrap">
<div class="card">
<div class="brand">dezky · scheduling</div>
<h1>Booking</h1>
<p class="mute">Open the specific booking link you were given to choose a time.</p>
</div>
</main>
</template>
<style scoped>
.wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 16px; }
.card { max-width: 460px; text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 40px 28px; }
.brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-mute); }
h1 { font-size: 24px; margin: 8px 0; }
.mute { color: var(--text-mute); }
</style>
+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>
+7087
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1a1a1a"/><path d="M9 8v16M9 13h11a4 4 0 0 1 0 8H9" fill="none" stroke="#D4FF3A" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 257 B

@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/bookings`, { body })
})
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/holds`, { body })
})
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
return forward(event, 'GET', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}`)
})
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const query = getQuery(event)
return forward(event, 'GET', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/slots`, { query })
})
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
const body = await readBody(event).catch(() => ({}))
return forward(event, 'POST', `/api/v1/public/bookings/${seg(token)}/cancel`, { body })
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
return forward(event, 'GET', `/api/v1/public/bookings/${seg(token)}`)
})
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/bookings/${seg(token)}/reschedule`, { body })
})
+23
View File
@@ -0,0 +1,23 @@
import type { H3Event } from 'h3'
// Forward a request to platform-api's public scheduling API, preserving upstream
// status codes + messages so the client sees a real 404/409/503 instead of a
// generic 500. The internal API hostname never reaches the browser.
export async function forward(
_event: H3Event,
method: 'GET' | 'POST',
path: string,
opts: { query?: Record<string, any>; body?: any } = {},
): Promise<any> {
const base = useRuntimeConfig().platformApiUrl
try {
return await $fetch(base + path, { method, query: opts.query, body: opts.body })
} 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) })
}
}
// Encode a single path segment safely.
export const seg = (s: string) => encodeURIComponent(s)
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+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 }
}
+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',
]
+2
View File
@@ -62,6 +62,7 @@ const ADMIN_NAV: NavRow[] = [
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
{ id: 'scheduling', label: 'Scheduling', icon: 'calendar', href: '/admin/scheduling' },
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
@@ -121,6 +122,7 @@ const currentId = computed(() => {
if (p.startsWith('/admin/users')) return 'users'
if (p.startsWith('/admin/mail')) return 'mail'
if (p.startsWith('/admin/meetings')) return 'meetings'
if (p.startsWith('/admin/scheduling')) return 'scheduling'
if (p.startsWith('/admin/chat')) return 'chat'
if (p.startsWith('/admin/domains')) return 'domains'
if (p.startsWith('/admin/storage')) return 'storage'
+715
View File
@@ -0,0 +1,715 @@
<script setup lang="ts">
// dezky Scheduling admin. Make a workspace user bookable (auto-provisions their
// Stalwart calendar credential), then configure event types + availability and
// view bookings. Public pages live on booking.dezky.eu/:tenant/:host/:eventType.
interface Host {
_id: string
email: string
displayName: string
slug: string
timezone: string
isActive: boolean
defaultCalendarId?: string
}
interface EventType {
_id: string
slug: string
title: string
description?: string
durationMinutes: number
slotIntervalMinutes: number
bufferBeforeMinutes: number
bufferAfterMinutes: number
minimumNoticeMinutes: number
maximumDaysInFuture: number
availabilityScheduleId: string
locationType: string
isActive: boolean
}
interface MinuteInterval { startMinute: number; endMinute: number }
interface WeeklyRule { dayOfWeek: number; intervals: MinuteInterval[] }
interface Availability { _id: string; name: string; timezone: string; weeklyRules: WeeklyRule[] }
interface Booking {
_id: string
status: string
startUtc: string
endUtc: string
attendeeName: string
attendeeEmail: string
eventTypeId: string
}
const toast = useToast()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { request } = useApiFetch()
const bookingBase = 'https://booking.dezky.local'
const base = computed(() => `/api/tenants/${slug.value}/scheduling`)
function toastErr(err: unknown, title: string) {
const e = err as { data?: { message?: string | string[] }; statusMessage?: string; message?: string }
const m = e?.data?.message ?? e?.statusMessage ?? e?.message ?? 'Unknown error'
toast.bad(title, Array.isArray(m) ? m.join(', ') : String(m))
}
const minToTime = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
// Generic confirm dialog (deletes + cancellations route through this).
const confirmBox = reactive({ open: false, title: '', message: '', confirmLabel: 'Confirm', busy: false, action: null as null | (() => Promise<void>) })
function askConfirm(opts: { title: string; message: string; confirmLabel?: string; action: () => Promise<void> }) {
Object.assign(confirmBox, { confirmLabel: 'Confirm', ...opts, open: true, busy: false })
}
async function runConfirm() {
if (!confirmBox.action) return
confirmBox.busy = true
try {
await confirmBox.action()
confirmBox.open = false
} catch (err) {
toastErr(err, 'Action failed')
} finally {
confirmBox.busy = false
}
}
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
key: 'sched-hosts',
default: () => [],
immediate: !!slug.value,
watch: [slug],
})
const selectedHostId = ref<string | null>(null)
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? null)
const detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
const eventTypes = ref<EventType[]>([])
const availability = ref<Availability[]>([])
const bookings = ref<Booking[]>([])
async function selectHost(id: string) {
selectedHostId.value = id
detailTab.value = 'event-types'
await loadHostData()
}
async function loadHostData() {
if (!selectedHostId.value) return
try {
const id = selectedHostId.value
;[eventTypes.value, availability.value, bookings.value] = await Promise.all([
request(`${base.value}/hosts/${id}/event-types`) as Promise<EventType[]>,
request(`${base.value}/hosts/${id}/availability`) as Promise<Availability[]>,
request(`${base.value}/bookings?hostId=${id}`) as Promise<Booking[]>,
])
} catch (err) {
toastErr(err, 'Could not load host data')
}
}
// ── Workspace users (bookable candidates need a mailbox) ──
interface WsUser { _id: string; name: string; email: string; mailboxAddress?: string }
const { data: users } = await useFetch<WsUser[]>(() => `/api/tenants/${slug.value}/users`, {
key: 'sched-users', default: () => [], immediate: !!slug.value, watch: [slug],
})
const hostEmails = computed(() => new Set((hosts.value ?? []).map((h) => h.email)))
const takenSlugs = computed(() => new Set((hosts.value ?? []).map((h) => h.slug)))
// Only users with a mailbox, and not already a host.
const availableUsers = computed(() =>
(users.value ?? []).filter((u) => !!u.mailboxAddress && !hostEmails.value.has(u.mailboxAddress!)),
)
const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
function slugify(name: string): string {
return name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '')
}
function freeSlug(baseSlug: string): string {
if (!baseSlug || !takenSlugs.value.has(baseSlug)) return baseSlug
let i = 2
while (takenSlugs.value.has(`${baseSlug}-${i}`)) i++
return `${baseSlug}-${i}`
}
// ── Create host ──
const hostOpen = ref(false)
const hostBusy = ref(false)
const hostForm = reactive({ userId: '', slug: '', timezone: 'Europe/Copenhagen' })
const slugTaken = computed(() => takenSlugs.value.has(hostForm.slug))
const slugValid = computed(() => SLUG_RE.test(hostForm.slug))
function openHost() {
const first = availableUsers.value[0]
hostForm.userId = first?._id ?? ''
hostForm.slug = first ? freeSlug(slugify(first.name)) : ''
hostForm.timezone = 'Europe/Copenhagen'
hostOpen.value = true
}
// Re-derive a free slug from the chosen user's display name.
function onHostUserChange() {
const u = availableUsers.value.find((x) => x._id === hostForm.userId)
if (u) hostForm.slug = freeSlug(slugify(u.name))
}
async function submitHost() {
if (!hostForm.userId || !slugValid.value || slugTaken.value) return
hostBusy.value = true
try {
await request(`${base.value}/hosts`, { method: 'POST', body: { ...hostForm } })
toast.ok('Host created', 'Calendar access was provisioned automatically.')
hostOpen.value = false
await refreshHosts()
} catch (err) {
toastErr(err, 'Could not create host')
} finally {
hostBusy.value = false
}
}
// ── Create availability ──
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const availOpen = ref(false)
const availBusy = ref(false)
const availForm = reactive({
name: 'Working hours',
timezone: 'Europe/Copenhagen',
days: DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' })),
})
const availEditingId = ref<string | null>(null)
function openAvail(a?: Availability) {
if (a) {
availEditingId.value = a._id
availForm.name = a.name
availForm.timezone = a.timezone
availForm.days = DAYS.map((_, i) => {
const intv = a.weeklyRules.find((r) => r.dayOfWeek === i)?.intervals?.[0]
return intv
? { enabled: true, start: minToTime(intv.startMinute), end: minToTime(intv.endMinute) }
: { enabled: false, start: '09:00', end: '17:00' }
})
} else {
availEditingId.value = null
availForm.name = 'Working hours'
availForm.timezone = selectedHost.value?.timezone ?? 'Europe/Copenhagen'
availForm.days = DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' }))
}
availOpen.value = true
}
const timeToMin = (t: string) => { const [h, m] = t.split(':').map(Number); return h * 60 + m }
async function submitAvail() {
if (!selectedHostId.value) return
availBusy.value = true
try {
const weeklyRules: WeeklyRule[] = availForm.days
.map((d, i) => ({ dayOfWeek: i, enabled: d.enabled, start: d.start, end: d.end }))
.filter((d) => d.enabled && timeToMin(d.end) > timeToMin(d.start))
.map((d) => ({ dayOfWeek: d.dayOfWeek, intervals: [{ startMinute: timeToMin(d.start), endMinute: timeToMin(d.end) }] }))
const body = { name: availForm.name, timezone: availForm.timezone, weeklyRules }
if (availEditingId.value) {
await request(`${base.value}/availability/${availEditingId.value}`, { method: 'PATCH', body })
toast.ok('Availability updated')
} else {
await request(`${base.value}/hosts/${selectedHostId.value}/availability`, { method: 'POST', body })
toast.ok('Availability saved')
}
availOpen.value = false
await loadHostData()
} catch (err) {
toastErr(err, 'Could not save availability')
} finally {
availBusy.value = false
}
}
function deleteAvail(a: Availability) {
askConfirm({
title: 'Delete availability schedule',
message: `Delete “${a.name}”? Event types using it must be reassigned or deleted first.`,
confirmLabel: 'Delete',
action: async () => {
await request(`${base.value}/availability/${a._id}`, { method: 'DELETE' })
toast.ok('Schedule deleted')
await loadHostData()
},
})
}
function cancelBookingAdmin(b: Booking) {
askConfirm({
title: 'Cancel booking',
message: `Cancel ${b.attendeeName}s booking? Theyll be emailed a cancellation and the calendar event is removed.`,
confirmLabel: 'Cancel booking',
action: async () => {
await request(`${base.value}/bookings/${b._id}/cancel`, { method: 'POST', body: { reason: 'Cancelled by host' } })
toast.ok('Booking cancelled')
await loadHostData()
},
})
}
// ── Create event type ──
const etOpen = ref(false)
const etBusy = ref(false)
const etForm = reactive({
title: '',
slug: '',
durationMinutes: 30,
slotIntervalMinutes: 15,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
minimumNoticeMinutes: 60,
maximumDaysInFuture: 60,
availabilityScheduleId: '',
locationType: 'jitsi',
})
const etEditingId = ref<string | null>(null)
const etSlugTouched = ref(false)
const etSlugValid = computed(() => SLUG_RE.test(etForm.slug))
const etValid = computed(() => etForm.title.trim().length > 0 && etSlugValid.value && !!etForm.availabilityScheduleId)
function openEt(et?: EventType) {
if (et) {
etEditingId.value = et._id
Object.assign(etForm, {
title: et.title, slug: et.slug, durationMinutes: et.durationMinutes,
slotIntervalMinutes: et.slotIntervalMinutes, bufferBeforeMinutes: et.bufferBeforeMinutes,
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
locationType: et.locationType,
})
etSlugTouched.value = true // don't auto-rewrite an existing slug
} else {
etEditingId.value = null
Object.assign(etForm, {
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
})
etSlugTouched.value = false
}
etOpen.value = true
}
// Auto-derive the slug from the title until the user edits the slug themselves.
function onEtTitle() {
if (!etSlugTouched.value) etForm.slug = slugify(etForm.title)
}
async function submitEt() {
if (!selectedHostId.value || !etValid.value) return
etBusy.value = true
try {
if (etEditingId.value) {
// slug is immutable after creation (public links would break) → omit it.
const { slug: _slug, ...patch } = { ...etForm }
await request(`${base.value}/event-types/${etEditingId.value}`, { method: 'PATCH', body: patch })
toast.ok('Event type updated')
} else {
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: { ...etForm } })
toast.ok('Event type created')
}
etOpen.value = false
await loadHostData()
} catch (err) {
toastErr(err, etEditingId.value ? 'Could not update event type' : 'Could not create event type')
} finally {
etBusy.value = false
}
}
function deleteEt(et: EventType) {
askConfirm({
title: 'Delete event type',
message: `Delete “${et.title}”? Its public page stops accepting new bookings. Existing bookings are unaffected.`,
confirmLabel: 'Delete',
action: async () => {
await request(`${base.value}/event-types/${et._id}`, { method: 'DELETE' })
toast.ok('Event type deleted')
await loadHostData()
},
})
}
// ── Reschedule a booking (admin) ──
interface DaySlots { key: string; label: string; slots: { startUtc: string }[] }
const reschedule = reactive({
open: false,
busy: false,
booking: null as Booking | null,
hostTz: 'Europe/Copenhagen',
state: 'idle' as 'idle' | 'loading' | 'ready' | 'error',
days: [] as DaySlots[],
selectedKey: '',
})
const reschedDay = computed(() => reschedule.days.find((d) => d.key === reschedule.selectedKey) ?? null)
async function openReschedule(b: Booking) {
const et = eventTypes.value.find((e) => e._id === b.eventTypeId)
if (!et || !selectedHost.value) {
toast.bad('Cannot reschedule', 'The event type for this booking no longer exists.')
return
}
Object.assign(reschedule, {
open: true, busy: false, booking: b, hostTz: selectedHost.value.timezone, state: 'loading', days: [], selectedKey: '',
})
try {
const from = new Date().toISOString()
const to = new Date(Date.now() + 14 * 86_400_000).toISOString()
const res = (await request(
`/api/scheduling-slots/${slug.value}/${selectedHost.value.slug}/${et.slug}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&timezone=${encodeURIComponent(reschedule.hostTz)}`,
)) as { slots: { startUtc: string }[] }
reschedule.days = groupByDay(res.slots, reschedule.hostTz)
reschedule.selectedKey = reschedule.days[0]?.key ?? ''
reschedule.state = 'ready'
} catch (err) {
reschedule.state = 'error'
toastErr(err, 'Could not load times')
}
}
function groupByDay(slots: { startUtc: string }[], tz: string): DaySlots[] {
const keyFmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })
const labelFmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short', day: 'numeric', month: 'short' })
const map = new Map<string, DaySlots>()
for (const s of slots) {
const d = new Date(s.startUtc)
const key = keyFmt.format(d)
if (!map.has(key)) map.set(key, { key, label: labelFmt.format(d), slots: [] })
map.get(key)!.slots.push(s)
}
return [...map.values()]
}
function reschedTime(iso: string): string {
return new Intl.DateTimeFormat('en-GB', { timeZone: reschedule.hostTz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
}
async function confirmReschedule(startUtc: string) {
if (!reschedule.booking) return
reschedule.busy = true
try {
await request(`${base.value}/bookings/${reschedule.booking._id}/reschedule`, { method: 'POST', body: { startUtc } })
toast.ok('Booking rescheduled', 'The attendee has been emailed the new time.')
reschedule.open = false
await loadHostData()
} catch (err) {
toastErr(err, 'Could not reschedule')
} finally {
reschedule.busy = false
}
}
function publicUrl(et: EventType): string {
return `${bookingBase}/${slug.value}/${selectedHost.value?.slug}/${et.slug}`
}
async function copyUrl(et: EventType) {
try {
await navigator.clipboard.writeText(publicUrl(et))
toast.ok('Link copied')
} catch {
toast.bad('Copy failed', publicUrl(et))
}
}
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
const detailTabs = computed(() => [
{ value: 'event-types', label: 'Event types', count: eventTypes.value.length },
{ value: 'availability', label: 'Availability', count: availability.value.length },
{ value: 'bookings', label: 'Bookings', count: bookings.value.length },
])
</script>
<template>
<div>
<PageHeader eyebrow="Workspace" title="Scheduling" subtitle="Public booking pages backed by your team's calendars.">
<template #actions>
<UiButton variant="primary" @click="openHost">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add bookable host
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-if="!hosts || !hosts.length" class="notice">
No bookable hosts yet. Add a host to create their booking pages calendar access is provisioned automatically.
</Card>
<div v-else class="layout">
<!-- Hosts list -->
<aside class="hosts">
<button
v-for="h in hosts"
:key="h._id"
class="hostrow"
:class="{ active: h._id === selectedHostId }"
@click="selectHost(h._id)"
>
<div class="hname">{{ h.displayName }}</div>
<div class="hmail">{{ h.email }}</div>
<Badge :tone="h.isActive ? 'ok' : 'neutral'" dot>{{ h.isActive ? 'active' : 'inactive' }}</Badge>
</button>
</aside>
<!-- Detail -->
<section class="detail">
<Card v-if="!selectedHost" class="notice">Select a host to manage their event types and availability.</Card>
<template v-else>
<Tabs v-model="detailTab" :items="detailTabs" />
<!-- Event types -->
<div v-if="detailTab === 'event-types'" class="pane">
<div class="panehead">
<span class="mute">{{ eventTypes.length }} event type(s)</span>
<UiButton variant="secondary" :disabled="!availability.length" @click="openEt">New event type</UiButton>
</div>
<p v-if="!availability.length" class="hint">Create an availability schedule first.</p>
<Card v-for="et in eventTypes" :key="et._id" class="item">
<div class="itemmain">
<div class="ititle">{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span></div>
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
</div>
<div class="itemactions">
<UiButton variant="ghost" @click="copyUrl(et)"><UiIcon name="copy" :size="14" /> Copy</UiButton>
<UiButton variant="ghost" @click="openEt(et)">Edit</UiButton>
<UiButton variant="ghost" @click="deleteEt(et)">Delete</UiButton>
</div>
</Card>
</div>
<!-- Availability -->
<div v-else-if="detailTab === 'availability'" class="pane">
<div class="panehead">
<span class="mute">{{ availability.length }} schedule(s)</span>
<UiButton variant="secondary" @click="openAvail">New schedule</UiButton>
</div>
<Card v-for="a in availability" :key="a._id" class="item">
<div class="itemmain">
<div class="ititle">{{ a.name }} <span class="mute">· {{ a.timezone }}</span></div>
<div class="mute small">
{{ a.weeklyRules.map((r) => DAYS[r.dayOfWeek]).join(', ') || 'No days set' }}
</div>
</div>
<div class="itemactions">
<UiButton variant="ghost" @click="openAvail(a)">Edit</UiButton>
<UiButton variant="ghost" @click="deleteAvail(a)">Delete</UiButton>
</div>
</Card>
</div>
<!-- Bookings -->
<div v-else class="pane">
<Card v-if="!bookings.length" class="notice">No bookings yet.</Card>
<Card v-for="b in bookings" :key="b._id" class="item">
<div class="itemmain">
<div class="ititle">{{ b.attendeeName }} <span class="mute">· {{ b.attendeeEmail }}</span></div>
<div class="mute small">{{ fmtDateTime(b.startUtc) }} {{ fmtDateTime(b.endUtc) }}</div>
</div>
<div class="itemactions">
<Badge :tone="b.status === 'confirmed' ? 'ok' : b.status === 'cancelled' ? 'bad' : 'neutral'">{{ b.status }}</Badge>
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="openReschedule(b)">Reschedule</UiButton>
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="cancelBookingAdmin(b)">Cancel</UiButton>
</div>
</Card>
</div>
</template>
</section>
</div>
</div>
<!-- Add host modal -->
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
<div class="form-stack">
<p v-if="!availableUsers.length" class="hint">
No eligible users. A bookable host needs a workspace mailbox (and isn't already a host) add a user with a mailbox under Users &amp; groups first.
</p>
<template v-else>
<label class="field"><Eyebrow>User</Eyebrow>
<select class="input" v-model="hostForm.userId" @change="onHostUserChange">
<option v-for="u in availableUsers" :key="u._id" :value="u._id">{{ u.name }} {{ u.mailboxAddress }}</option>
</select>
<span class="slughint">Calendar access is provisioned automatically no connect step.</span>
</label>
<label class="field"><Eyebrow>URL slug</Eyebrow>
<input class="input" v-model="hostForm.slug" placeholder="anne" />
<span class="slughint" :class="{ bad: !!hostForm.slug && (slugTaken || !slugValid) }">
<template v-if="!hostForm.slug">Used in the public booking link.</template>
<template v-else-if="!slugValid">Lowercase letters, numbers and hyphens (240 chars).</template>
<template v-else-if="slugTaken">{{ hostForm.slug }} is already taken.</template>
<template v-else>booking.dezky.local/{{ slug }}/{{ hostForm.slug }} · available </template>
</span>
</label>
<label class="field"><Eyebrow>Timezone</Eyebrow>
<select class="input" v-model="hostForm.timezone">
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
</select>
</label>
</template>
</div>
<template #footer>
<UiButton variant="ghost" @click="hostOpen = false">Cancel</UiButton>
<UiButton
variant="primary"
:disabled="hostBusy || !availableUsers.length || !hostForm.userId || slugTaken || !slugValid"
@click="submitHost"
>{{ hostBusy ? 'Provisioning…' : 'Create host' }}</UiButton>
</template>
</Modal>
<!-- Availability modal -->
<Modal :open="availOpen" eyebrow="Scheduling" :title="availEditingId ? 'Edit availability schedule' : 'New availability schedule'" size="md" @close="availOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" v-model="availForm.name" /></label>
<label class="field"><Eyebrow>Timezone</Eyebrow>
<select class="input" v-model="availForm.timezone">
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
</select>
</label>
<div class="weekgrid">
<div v-for="(d, i) in availForm.days" :key="i" class="weekrow">
<label class="daytoggle"><input type="checkbox" v-model="d.enabled" /> {{ DAYS[i] }}</label>
<input class="input time" type="time" v-model="d.start" :disabled="!d.enabled" />
<span class="dash"></span>
<input class="input time" type="time" v-model="d.end" :disabled="!d.enabled" />
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="availOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="availBusy" @click="submitAvail">{{ availBusy ? 'Saving' : 'Save' }}</UiButton>
</template>
</Modal>
<!-- Event type modal -->
<Modal :open="etOpen" eyebrow="Scheduling" :title="etEditingId ? 'Edit event type' : 'New event type'" size="md" @close="etOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Title</Eyebrow><input class="input" v-model="etForm.title" @input="onEtTitle" placeholder="30-min consultation" /></label>
<label class="field"><Eyebrow>URL slug</Eyebrow>
<input class="input" v-model="etForm.slug" :disabled="!!etEditingId" @input="etSlugTouched = true" placeholder="consult" />
<span class="slughint" :class="{ bad: !etEditingId && !!etForm.slug && !etSlugValid }">
<template v-if="etEditingId">The slug cant change after creation (it would break public links).</template>
<template v-else-if="!etForm.slug">Part of the public booking link.</template>
<template v-else-if="!etSlugValid">Lowercase letters, numbers and hyphens (240 chars).</template>
<template v-else>/{{ selectedHost?.slug }}/{{ etForm.slug }}</template>
</span>
</label>
<div class="row2">
<label class="field"><Eyebrow>Duration (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.durationMinutes" /></label>
<label class="field"><Eyebrow>Slot step (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.slotIntervalMinutes" /></label>
</div>
<div class="row2">
<label class="field"><Eyebrow>Buffer before</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferBeforeMinutes" /></label>
<label class="field"><Eyebrow>Buffer after</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferAfterMinutes" /></label>
</div>
<div class="row2">
<label class="field"><Eyebrow>Min notice (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.minimumNoticeMinutes" /></label>
<label class="field"><Eyebrow>Horizon (days)</Eyebrow><input class="input" type="number" v-model.number="etForm.maximumDaysInFuture" /></label>
</div>
<label class="field"><Eyebrow>Availability schedule</Eyebrow>
<select class="input" v-model="etForm.availabilityScheduleId">
<option v-for="a in availability" :key="a._id" :value="a._id">{{ a.name }}</option>
</select>
</label>
<label class="field"><Eyebrow>Location</Eyebrow>
<select class="input" v-model="etForm.locationType">
<option value="jitsi">dezky Meet (video)</option><option value="phone">Phone</option>
<option value="in_person">In person</option><option value="custom">Custom</option>
</select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="etBusy || !etValid" @click="submitEt">{{ etBusy ? 'Saving' : etEditingId ? 'Save changes' : 'Create' }}</UiButton>
</template>
</Modal>
<!-- Reschedule modal -->
<Modal :open="reschedule.open" eyebrow="Scheduling" title="Reschedule booking" size="md" @close="reschedule.open = false">
<div class="form-stack">
<p v-if="reschedule.booking" class="mute small">
{{ reschedule.booking.attendeeName }} currently {{ fmtDateTime(reschedule.booking.startUtc) }}.
Pick a new time (shown in {{ reschedule.hostTz }}).
</p>
<p v-if="reschedule.state === 'loading'" class="mute">Loading times</p>
<p v-else-if="reschedule.state === 'error'" class="err">Couldnt load available times.</p>
<template v-else-if="reschedule.state === 'ready'">
<p v-if="!reschedule.days.length" class="mute">No available times in the next 14 days.</p>
<template v-else>
<div class="days">
<button
v-for="d in reschedule.days"
:key="d.key"
class="day"
:class="{ active: d.key === reschedule.selectedKey }"
@click="reschedule.selectedKey = d.key"
>
<span class="dlabel">{{ d.label }}</span>
<span class="dcount">{{ d.slots.length }}</span>
</button>
</div>
<div v-if="reschedDay" class="slotgrid">
<button
v-for="s in reschedDay.slots"
:key="s.startUtc"
class="slotbtn"
:disabled="reschedule.busy"
@click="confirmReschedule(s.startUtc)"
>{{ reschedTime(s.startUtc) }}</button>
</div>
</template>
</template>
</div>
<template #footer>
<UiButton variant="ghost" @click="reschedule.open = false">Close</UiButton>
</template>
</Modal>
<ConfirmDialog
:open="confirmBox.open"
eyebrow="Scheduling"
:title="confirmBox.title"
:confirm-label="confirmBox.confirmLabel"
:busy="confirmBox.busy"
tone="danger"
@close="confirmBox.open = false"
@confirm="runConfirm"
>
{{ confirmBox.message }}
</ConfirmDialog>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px; }
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
.hosts { display: flex; flex-direction: column; gap: 8px; }
.hostrow { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.hostrow.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.hname { font-weight: 600; }
.hmail { font-size: 12px; color: var(--text-mute); }
.detail { min-width: 0; }
.pane { margin-top: 14px; display: flex; flex-direction: column; gap: 10px; }
.panehead { display: flex; align-items: center; justify-content: space-between; }
.item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
.itemmain { min-width: 0; }
.itemactions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.ititle { font-weight: 600; }
.link { font-size: 12px; color: var(--text-mute); word-break: break-all; }
.mute { color: var(--text-mute); }
.small { font-size: 12px; }
.hint { color: var(--warn, #9a6a00); font-size: 13px; }
.slughint { font-size: 12px; color: var(--text-mute); }
.slughint.bad { color: var(--bad, #c0362c); }
.err { color: var(--bad, #c0362c); font-size: 13px; }
.days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; }
.day { flex: 0 0 auto; min-width: 78px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.day.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.dlabel { font-size: 12px; }
.dcount { font-size: 11px; color: var(--text-mute); }
.slotgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: 8px; margin-top: 12px; }
.slotbtn { height: 38px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; }
.slotbtn:hover:not(:disabled) { border-color: var(--text); }
.slotbtn:disabled { opacity: .5; cursor: not-allowed; }
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text); }
.weekgrid { display: flex; flex-direction: column; gap: 8px; }
.weekrow { display: grid; grid-template-columns: 90px 1fr auto 1fr; align-items: center; gap: 8px; }
.daytoggle { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.time { width: 100%; }
.dash { text-align: center; color: var(--text-mute); }
</style>
@@ -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) })
}
})
@@ -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
+32
View File
@@ -0,0 +1,32 @@
// Jest (ESM + TypeScript via ts-jest). The codebase is NodeNext ESM with `.js`
// import specifiers, so we strip the extension in moduleNameMapper to resolve
// `./x.js` → `./x.ts` under jest. Run with NODE_OPTIONS=--experimental-vm-modules
// (set in the package.json "test" script).
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/?(*.)+(spec).ts'],
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
tsconfig: {
// Emit ESM regardless of package.json "type" (NodeNext would emit CJS
// here and break under jest's ESM VM → "exports is not defined").
module: 'ESNext',
moduleResolution: 'Bundler',
isolatedModules: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
verbatimModuleSyntax: false,
},
},
],
},
}
+8 -1
View File
@@ -9,7 +9,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -19,9 +19,12 @@
"@nestjs/platform-fastify": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ical-generator": "^8.0.1",
"jose": "^5.9.0",
"luxon": "^3.5.0",
"mongoose": "^8.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
@@ -30,7 +33,11 @@
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/jest": "^29.5.12",
"@types/luxon": "^3.4.2",
"@types/node": "^20.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.5.0",
"ts-node": "^10.9.2"
},
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,7 @@ import { MailModule } from './mail/mail.module.js'
import { MeModule } from './me/me.module.js'
import { PartnersModule } from './partners/partners.module.js'
import { PricesModule } from './prices/prices.module.js'
import { SchedulingModule } from './scheduling/scheduling.module.js'
import { SeedModule } from './seed/seed.module.js'
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
import { TenantsModule } from './tenants/tenants.module.js'
@@ -37,6 +38,7 @@ import { UsersModule } from './users/users.module.js'
FlagsModule,
BillingModule,
IngestModule,
SchedulingModule,
SeedModule,
],
})
@@ -269,6 +269,50 @@ export class StalwartClient {
}
}
// The Stalwart account id whose primary address matches `email`, or undefined.
// Stalwart's account query has no email filter, so we list + match in memory.
async findAccountIdByEmail(email: string): Promise<string | undefined> {
const target = email.trim().toLowerCase()
const accounts = await this.listAccountsWithAliases()
return accounts.find((a) => a.emailAddress?.toLowerCase() === target)?.id
}
// ── App passwords (per-account credentials for on-behalf calendar access) ────
// Used by dezky Scheduling to obtain a scoped credential for a host without a
// user-facing "connect calendar" step. Admin mints on-behalf by passing the
// target account id; the returned `secret` is shown once and must be stored
// encrypted by the caller (it is never logged here).
async createAppPassword(accountId: string, description: string): Promise<{ id: string; secret: string }> {
const resp = await this.jmap([
[
'x:AppPassword/set',
{
accountId,
create: { ap: { description, permissions: { '@type': 'Inherit' }, allowedIps: {} } },
},
'0',
],
])
const created = resp[0][1].created?.ap
if (!created?.id || !created?.secret) {
throw new Error(
`Stalwart app-password create failed for account ${accountId}: ${JSON.stringify(resp[0][1].notCreated)}`,
)
}
return { id: created.id, secret: created.secret }
}
async deleteAppPassword(accountId: string, id: string): Promise<void> {
const resp = await this.jmap([['x:AppPassword/set', { accountId, destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) return
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart app-password delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
}
}
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
// Every mailbox + its aliases. Stalwart's account query has no domain filter,
@@ -0,0 +1,68 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import {
AvailabilitySchedule,
AvailabilityScheduleDocument,
DateOverride,
WeeklyRule,
} from '../../schemas/availability-schedule.schema.js'
import { EventType, EventTypeDocument } from '../../schemas/event-type.schema.js'
export interface AvailabilityInput {
name: string
timezone: string
weeklyRules: WeeklyRule[]
dateOverrides?: DateOverride[]
}
@Injectable()
export class AvailabilityService {
constructor(
@InjectModel(AvailabilitySchedule.name)
private readonly model: Model<AvailabilityScheduleDocument>,
@InjectModel(EventType.name)
private readonly eventTypeModel: Model<EventTypeDocument>,
) {}
list(tenantId: Types.ObjectId, hostId: Types.ObjectId): Promise<AvailabilityScheduleDocument[]> {
return this.model.find({ tenantId, hostId }).sort({ name: 1 }).exec()
}
create(tenantId: Types.ObjectId, hostId: Types.ObjectId, input: AvailabilityInput): Promise<AvailabilityScheduleDocument> {
return this.model.create({
tenantId,
hostId,
name: input.name,
timezone: input.timezone,
weeklyRules: input.weeklyRules,
dateOverrides: input.dateOverrides ?? [],
})
}
async get(tenantId: Types.ObjectId, id: string): Promise<AvailabilityScheduleDocument> {
const doc = await this.model.findOne({ _id: id, tenantId }).exec()
if (!doc) throw new NotFoundException('Availability schedule not found')
return doc
}
async update(tenantId: Types.ObjectId, id: string, input: Partial<AvailabilityInput>): Promise<AvailabilityScheduleDocument> {
const doc = await this.get(tenantId, id)
if (input.name !== undefined) doc.name = input.name
if (input.timezone !== undefined) doc.timezone = input.timezone
if (input.weeklyRules !== undefined) doc.weeklyRules = input.weeklyRules
if (input.dateOverrides !== undefined) doc.dateOverrides = input.dateOverrides
return doc.save()
}
async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
// Don't orphan event types: a deleted schedule would break their slot
// computation. Require the caller to reassign/delete those first.
const inUse = await this.eventTypeModel.countDocuments({ tenantId, availabilityScheduleId: id }).exec()
if (inUse > 0) {
throw new ConflictException(`This schedule is used by ${inUse} event type(s). Reassign or delete them first.`)
}
const res = await this.model.deleteOne({ _id: id, tenantId }).exec()
if (res.deletedCount === 0) throw new NotFoundException('Availability schedule not found')
}
}
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator'
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class MinuteIntervalDto {
@IsInt() @Min(0) @Max(1440) startMinute!: number
@IsInt() @Min(0) @Max(1440) endMinute!: number
}
export class WeeklyRuleDto {
@IsInt() @Min(0) @Max(6) dayOfWeek!: number
@IsArray()
@ValidateNested({ each: true })
@Type(() => MinuteIntervalDto)
intervals!: MinuteIntervalDto[]
}
export class DateOverrideDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date!: string
@IsBoolean()
isUnavailable!: boolean
@IsArray()
@ValidateNested({ each: true })
@Type(() => MinuteIntervalDto)
intervals!: MinuteIntervalDto[]
}
export class CreateAvailabilityDto {
@IsString()
@MaxLength(120)
name!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
@IsArray()
@ValidateNested({ each: true })
@Type(() => WeeklyRuleDto)
weeklyRules!: WeeklyRuleDto[]
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateOverrideDto)
dateOverrides?: DateOverrideDto[]
}
export class UpdateAvailabilityDto {
@IsOptional() @IsString() @MaxLength(120) name?: string
@IsOptional()
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone?: string
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WeeklyRuleDto)
weeklyRules?: WeeklyRuleDto[]
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateOverrideDto)
dateOverrides?: DateOverrideDto[]
}
@@ -0,0 +1,342 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes, randomUUID } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventTypeDocument } from '../../schemas/event-type.schema.js'
import { HostDocument } from '../../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
import { confirmationEmail, cancellationEmail } from '../email/booking-templates.js'
import { buildBookingIcs } from '../email/ics.js'
import { JmapMailer } from '../email/jmap-mailer.service.js'
import { SlotService } from '../slots/slot.service.js'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
const HOLD_MS = 10 * 60 * 1000
// Tenant identity needed for branding the calendar event + email.
export interface BookingTenantRef {
_id: Types.ObjectId
slug: string
name: string
brandColor?: string
}
export interface BookingContext {
tenant: BookingTenantRef
host: HostDocument
eventType: EventTypeDocument
}
export interface ConfirmBookingInput {
startUtc: Date
attendeeName: string
attendeeEmail: string
attendeeTimezone: string
attendeeNotes?: string
holdId?: string
}
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name)
private readonly bookingPublicUrl: string
private readonly meetBaseUrl: string
constructor(
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
private readonly slots: SlotService,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
private readonly mailer: JmapMailer,
config: ConfigService,
) {
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
this.meetBaseUrl = (config.get<string>('MEET_PUBLIC_URL') ?? 'https://meet.dezky.local').replace(/\/$/, '')
}
// ── Holds (optional reservation during checkout) ───────────────────────────
async hold(ctx: BookingContext, startUtc: Date): Promise<{ holdId: string; expiresAt: Date }> {
const endUtc = new Date(startUtc.getTime() + ctx.eventType.durationMinutes * 60_000)
const holdId = randomBytes(18).toString('hex')
const expiresAt = new Date(Date.now() + HOLD_MS)
try {
await this.lockModel.create({
tenantId: ctx.tenant._id,
hostId: ctx.host._id,
startUtc,
endUtc,
expiresAt,
holdToken: holdId,
bookingId: null,
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('That time is currently being booked by someone else.')
throw err
}
return { holdId, expiresAt }
}
// ── Confirm ────────────────────────────────────────────────────────────────
async confirm(ctx: BookingContext, input: ConfirmBookingInput): Promise<BookingDocument> {
return this.createConfirmedBooking(ctx, input, {})
}
private async createConfirmedBooking(
ctx: BookingContext,
input: ConfirmBookingInput,
opts: { rescheduledFromBookingId?: Types.ObjectId },
): Promise<BookingDocument> {
const { host, eventType, tenant } = ctx
if (!host.isActive || !eventType.isActive) throw new BadRequestException('This booking page is not available.')
const now = new Date()
const startUtc = input.startUtc
const endUtc = new Date(startUtc.getTime() + eventType.durationMinutes * 60_000)
// (a) Validate the time is genuinely offered AND free against live free/busy
// (this performs the §8.2 live re-check via the calendar gateway).
const offered = await this.slots.availableSlots(host, eventType, startUtc, endUtc, now)
if (!offered.some((s) => s.startUtc.getTime() === startUtc.getTime())) {
throw new ConflictException('That time is no longer available.')
}
// (b) Persist a pending booking so we have an id to attach to the lock.
const calendarEventUid = randomUUID()
const manageToken = randomBytes(24).toString('hex')
const location = this.resolveLocation(ctx)
const booking = await this.bookingModel.create({
tenantId: tenant._id,
eventTypeId: eventType._id,
hostId: host._id,
status: 'pending',
startUtc,
endUtc,
attendeeName: input.attendeeName,
attendeeEmail: input.attendeeEmail,
attendeeTimezone: input.attendeeTimezone,
attendeeNotes: input.attendeeNotes,
calendarEventUid,
manageToken,
locationType: location.type,
locationUrl: location.url,
rescheduledFromBookingId: opts.rescheduledFromBookingId ?? null,
reminderState: 'none',
})
// (c) §8.2 layer 1 — atomic slot claim. Claim our own hold by token if given;
// otherwise insert a fresh unique lock (dup-key => slot already taken).
let claimed = false
if (input.holdId) {
const upd = await this.lockModel
.findOneAndUpdate(
{ hostId: host._id, startUtc, holdToken: input.holdId, bookingId: null },
{ $set: { bookingId: booking._id, expiresAt: null, endUtc } },
)
.exec()
claimed = !!upd
}
if (!claimed) {
try {
await this.lockModel.create({
tenantId: tenant._id,
hostId: host._id,
startUtc,
endUtc,
expiresAt: null,
bookingId: booking._id,
holdToken: null,
})
} catch (err: any) {
await this.bookingModel.deleteOne({ _id: booking._id }).exec()
if (err?.code === 11000) throw new ConflictException('That time was just taken.')
throw err
}
}
// (d) Write to the host's Stalwart calendar; promote to confirmed on success.
let access: HostCalendarAccess
try {
access = await this.provisioner.resolveAccess(host)
const { id } = await this.gateway.createEvent(access, {
uid: calendarEventUid,
title: eventType.title,
description: input.attendeeNotes,
startUtc,
endUtc,
hostTimezone: host.timezone,
location: location.url,
hostEmail: host.email,
attendeeName: input.attendeeName,
attendeeEmail: input.attendeeEmail,
})
booking.calendarEventId = id
booking.status = 'confirmed'
await booking.save()
} catch (err) {
// Compensate: never leave a confirmed-looking booking with no calendar event.
await this.lockModel.deleteOne({ hostId: host._id, startUtc, bookingId: booking._id }).exec()
await this.bookingModel.deleteOne({ _id: booking._id }).exec()
this.logger.error(`Calendar write failed for ${host.email}: ${(err as Error).message}`)
throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
}
// (e) Branded confirmation email — best-effort (booking already valid).
this.sendEmail(ctx, booking, access, 'confirmation').catch((e) =>
this.logger.warn(`Confirmation email failed for ${booking.attendeeEmail}: ${e.message}`),
)
return booking
}
// ── Manage / cancel / reschedule ───────────────────────────────────────────
async getByManageToken(token: string): Promise<BookingDocument> {
const booking = await this.bookingModel.findOne({ manageToken: token }).exec()
if (!booking) throw new NotFoundException('Booking not found')
return booking
}
// Tenant-scoped lookup for the admin surface (cancel from the bookings list).
async getForTenant(tenantId: Types.ObjectId, id: string): Promise<BookingDocument> {
const booking = await this.bookingModel.findOne({ _id: id, tenantId }).exec()
if (!booking) throw new NotFoundException('Booking not found')
return booking
}
async cancel(token: string, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
return this.performCancel(await this.getByManageToken(token), reason, ctx)
}
// Admin cancel by booking doc (already tenant-checked by the caller).
async cancelResolved(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
return this.performCancel(booking, reason, ctx)
}
private async performCancel(booking: BookingDocument, reason: string | undefined, ctx: BookingContext): Promise<BookingDocument> {
if (booking.status === 'cancelled') return booking
const access = await this.provisioner.resolveAccess(ctx.host)
if (booking.calendarEventId) {
await this.gateway.deleteEvent(access, booking.calendarEventId).catch((e) =>
this.logger.warn(`Calendar delete failed for booking ${booking._id}: ${e.message}`),
)
}
booking.status = 'cancelled'
booking.cancelledAt = new Date()
booking.cancellationReason = reason
await booking.save()
await this.lockModel.deleteOne({ hostId: booking.hostId, startUtc: booking.startUtc, bookingId: booking._id }).exec()
this.sendEmail(ctx, booking, access, 'cancellation').catch((e) =>
this.logger.warn(`Cancellation email failed: ${e.message}`),
)
return booking
}
async reschedule(token: string, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
return this.performReschedule(await this.getByManageToken(token), newStartUtc, ctx)
}
// Admin reschedule by booking doc (already tenant-checked by the caller).
async rescheduleResolved(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
return this.performReschedule(old, newStartUtc, ctx)
}
private async performReschedule(old: BookingDocument, newStartUtc: Date, ctx: BookingContext): Promise<BookingDocument> {
if (old.status === 'cancelled') throw new BadRequestException('This booking was cancelled and cannot be rescheduled.')
// Create the replacement booking first (validates + claims the new slot).
const fresh = await this.createConfirmedBooking(
ctx,
{
startUtc: newStartUtc,
attendeeName: old.attendeeName,
attendeeEmail: old.attendeeEmail,
attendeeTimezone: old.attendeeTimezone,
attendeeNotes: old.attendeeNotes,
},
{ rescheduledFromBookingId: old._id },
)
// Tear down the old one (delete its calendar event, mark rescheduled).
const access = await this.provisioner.resolveAccess(ctx.host)
if (old.calendarEventId) {
await this.gateway.deleteEvent(access, old.calendarEventId).catch((e) =>
this.logger.warn(`Old event delete failed during reschedule: ${e.message}`),
)
}
old.status = 'rescheduled'
await old.save()
await this.lockModel.deleteOne({ hostId: old.hostId, startUtc: old.startUtc, bookingId: old._id }).exec()
return fresh
}
// ── Admin listing ──────────────────────────────────────────────────────────
listForTenant(tenantId: Types.ObjectId, hostId?: Types.ObjectId): Promise<BookingDocument[]> {
const filter: Record<string, unknown> = { tenantId }
if (hostId) filter.hostId = hostId
return this.bookingModel.find(filter).sort({ startUtc: -1 }).limit(500).exec()
}
// ── Helpers ────────────────────────────────────────────────────────────────
private resolveLocation(ctx: BookingContext): { type: BookingDocument['locationType']; url?: string } {
const et = ctx.eventType
if (et.locationType === 'jitsi') {
return { type: 'jitsi', url: `${this.meetBaseUrl}/${ctx.tenant.slug}-${randomBytes(6).toString('hex')}` }
}
return { type: et.locationType, url: et.locationDetails }
}
private async sendEmail(
ctx: BookingContext,
booking: BookingDocument,
access: HostCalendarAccess,
kind: 'confirmation' | 'cancellation',
): Promise<void> {
const emailCtx = {
brandName: ctx.tenant.name,
brandColor: ctx.tenant.brandColor,
eventTitle: ctx.eventType.title,
hostName: ctx.host.displayName,
attendeeName: booking.attendeeName,
startUtc: booking.startUtc,
endUtc: booking.endUtc,
attendeeTimezone: booking.attendeeTimezone,
location: booking.locationUrl,
manageUrl: `${this.bookingPublicUrl}/manage/${booking.manageToken}`,
}
const rendered = kind === 'confirmation' ? confirmationEmail(emailCtx) : cancellationEmail(emailCtx)
const ics = buildBookingIcs({
uid: booking.calendarEventUid,
start: booking.startUtc,
end: booking.endUtc,
summary: `${ctx.eventType.title} with ${ctx.host.displayName}`,
description: booking.attendeeNotes,
location: booking.locationUrl,
organizerName: ctx.host.displayName,
organizerEmail: ctx.host.email,
attendeeName: booking.attendeeName,
attendeeEmail: booking.attendeeEmail,
})
await this.mailer.send(access, {
to: booking.attendeeEmail,
toName: booking.attendeeName,
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
ics,
icsFilename: 'invite.ics',
})
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
// AES-256-GCM at-rest encryption for Stalwart host credentials (app passwords).
// The key comes from SCHEDULING_CREDENTIAL_KEY (64 hex chars = 32 bytes); in
// production this is sourced from KMS/sealed-secrets. We store ciphertext + iv +
// authTag separately (all base64) so the GCM auth tag is verified on every open —
// a tampered ciphertext throws rather than returning garbage. Secrets are NEVER
// logged: this module deals only in opaque buffers.
const ALGO = 'aes-256-gcm'
const IV_BYTES = 12 // GCM standard nonce length
export interface SealedSecret {
encryptedSecret: string // base64 ciphertext
iv: string // base64 nonce
authTag: string // base64 GCM tag
}
@Injectable()
export class CredentialCipher {
private readonly key: Buffer
constructor(config: ConfigService) {
const hex = config.get<string>('SCHEDULING_CREDENTIAL_KEY') ?? ''
const key = Buffer.from(hex, 'hex')
if (key.length !== 32) {
throw new Error(
'SCHEDULING_CREDENTIAL_KEY must be 32 bytes (64 hex chars). Generate with: openssl rand -hex 32',
)
}
this.key = key
}
seal(plaintext: string): SealedSecret {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALGO, this.key, iv)
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
return {
encryptedSecret: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
}
}
open(sealed: SealedSecret): string {
const decipher = createDecipheriv(ALGO, this.key, Buffer.from(sealed.iv, 'base64'))
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'))
const dec = Buffer.concat([
decipher.update(Buffer.from(sealed.encryptedSecret, 'base64')),
decipher.final(),
])
return dec.toString('utf8')
}
}
@@ -0,0 +1,98 @@
// Branded, dependency-free booking email templates (text + HTML). Per CLAUDE.md
// the brand surface is whitelabel: `brandName`/`brandColor` come from the tenant,
// not fixed dezky styling. End-user copy may be localized later; English for now.
export interface BookingEmailContext {
brandName: string
brandColor?: string
eventTitle: string
hostName: string
attendeeName: string
startUtc: Date
endUtc: Date
attendeeTimezone: string
location?: string
manageUrl: string
}
export interface RenderedEmail {
subject: string
text: string
html: string
}
function fmtRange(start: Date, end: Date, tz: string): string {
const date = new Intl.DateTimeFormat('en-GB', {
timeZone: tz, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).format(start)
const t = (d: Date) =>
new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(d)
return `${date}, ${t(start)}${t(end)} (${tz})`
}
function shell(accent: string, brandName: string, heading: string, bodyHtml: string): string {
return `<!doctype html><html><body style="margin:0;background:#f6f6f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a">
<div style="max-width:520px;margin:0 auto;padding:32px 16px">
<div style="background:#fff;border:1px solid #ececec;border-radius:14px;overflow:hidden">
<div style="height:6px;background:${accent}"></div>
<div style="padding:28px">
<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#888">${escapeHtml(brandName)}</div>
<h1 style="font-size:20px;margin:8px 0 16px">${escapeHtml(heading)}</h1>
${bodyHtml}
</div>
</div>
<p style="text-align:center;color:#aaa;font-size:12px;margin-top:16px">Powered by ${escapeHtml(brandName)}</p>
</div></body></html>`
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
}
export function confirmationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Confirmed: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking is confirmed.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
ctx.location ? `Location: ${ctx.location}` : '',
``,
`A calendar invite is attached.`,
`Need to change it? ${ctx.manageUrl}`,
].filter(Boolean).join('\n')
const html = shell(accent, ctx.brandName, 'Your booking is confirmed', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444">${escapeHtml(when)}</p>
${ctx.location ? `<p style="margin:0 0 4px;color:#444">${escapeHtml(ctx.location)}</p>` : ''}
<p style="margin:18px 0 0;font-size:14px;color:#666">A calendar invite (.ics) is attached.</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Reschedule or cancel</a></p>
`)
return { subject, text, html }
}
export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Cancelled: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking has been cancelled.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
``,
`You can book a new time here: ${ctx.manageUrl}`,
].join('\n')
const html = shell(accent, ctx.brandName, 'Your booking was cancelled', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444;text-decoration:line-through">${escapeHtml(when)}</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Book a new time</a></p>
`)
return { subject, text, html }
}
@@ -0,0 +1,36 @@
import ical, { ICalCalendarMethod } from 'ical-generator'
// Build a PUBLISH .ics for the attendee's "add to calendar". METHOD:PUBLISH (not
// REQUEST) because dezky is not acting as the iMIP organizer — we send a branded
// confirmation with an attachable event, not a scheduling invitation. The UID
// matches the booking's calendarEventUid so a re-send updates rather than dupes.
export interface BookingIcsInput {
uid: string
start: Date
end: Date
summary: string
description?: string
location?: string
organizerName: string
organizerEmail: string
attendeeName: string
attendeeEmail: string
}
export function buildBookingIcs(p: BookingIcsInput): string {
const cal = ical({
prodId: { company: 'dezky', product: 'scheduling', language: 'EN' },
method: ICalCalendarMethod.PUBLISH,
})
cal.createEvent({
id: p.uid,
start: p.start,
end: p.end,
summary: p.summary,
description: p.description,
location: p.location,
organizer: { name: p.organizerName, email: p.organizerEmail },
attendees: [{ name: p.attendeeName, email: p.attendeeEmail, rsvp: false }],
})
return cal.toString()
}
@@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
// Sends dezky-branded booking emails via JMAP through the host's own mailbox
// (From = host address). Uses the same app-password access as the calendar
// gateway — no separate SMTP surface. Flow: upload the .ics blob → Email/set a
// draft with text+html bodies and the .ics attachment → EmailSubmission/set to
// send. Never logs message bodies or credentials.
const CORE = 'urn:ietf:params:jmap:core'
const MAIL = 'urn:ietf:params:jmap:mail'
const SUBMISSION = 'urn:ietf:params:jmap:submission'
interface Session {
apiUrl: string
uploadUrl: string
primaryAccounts: Record<string, string>
}
type MethodResponse = [string, Record<string, any>, string]
export interface OutboundEmail {
to: string
toName: string
subject: string
text: string
html: string
ics: string
icsFilename: string
}
@Injectable()
export class JmapMailer {
private readonly logger = new Logger(JmapMailer.name)
async send(access: HostCalendarAccess, msg: OutboundEmail): Promise<void> {
const auth = `Basic ${Buffer.from(`${access.email}:${access.secret}`).toString('base64')}`
const origin = new URL(access.jmapSessionUrl).origin
const session = (await (await fetch(access.jmapSessionUrl, { headers: { Authorization: auth } })).json()) as Session
const accountId = session.primaryAccounts[MAIL]
if (!accountId) throw new Error('Host mailbox has no mail capability')
const apiUrl = origin + new URL(session.apiUrl, origin).pathname
// 1. Upload the .ics as a blob (internal hostname).
const uploadPath = new URL(session.uploadUrl.replace('{accountId}', accountId), origin).pathname
const upRes = await fetch(origin + uploadPath, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'text/calendar' },
body: msg.ics,
})
if (!upRes.ok) throw new Error(`blob upload ${upRes.status}`)
const blobId = ((await upRes.json()) as { blobId: string }).blobId
// 2. Resolve a filing mailbox (prefer Sent, else Drafts) and the submission
// identity (required by EmailSubmission/set) for the host address.
const meta = await this.call(apiUrl, auth, [CORE, MAIL, SUBMISSION], [
['Mailbox/get', { accountId, properties: ['role'] }, 'm'],
['Identity/get', { accountId, properties: ['email'] }, 'i'],
])
const mailboxes = (meta.find((r) => r[0] === 'Mailbox/get')?.[1]?.list ?? []) as Array<{ id: string; role?: string }>
const fileMailbox =
mailboxes.find((m) => m.role === 'sent')?.id ?? mailboxes.find((m) => m.role === 'drafts')?.id ?? mailboxes[0]?.id
if (!fileMailbox) throw new Error('Host mailbox has no filing folder')
const identities = (meta.find((r) => r[0] === 'Identity/get')?.[1]?.list ?? []) as Array<{ id: string; email?: string }>
const identityId =
identities.find((i) => i.email?.toLowerCase() === access.email.toLowerCase())?.id ?? identities[0]?.id
if (!identityId) throw new Error('Host mailbox has no submission identity')
// 3. Create the message + 4. submit it, in one request (back-referencing #msg).
const resp = await this.call(
apiUrl,
auth,
[CORE, MAIL, SUBMISSION],
[
[
'Email/set',
{
accountId,
create: {
msg: {
mailboxIds: { [fileMailbox]: true },
keywords: { $seen: true },
from: [{ email: access.email }],
to: [{ email: msg.to, name: msg.toName }],
subject: msg.subject,
bodyValues: {
t: { value: msg.text },
h: { value: msg.html },
},
textBody: [{ partId: 't', type: 'text/plain' }],
htmlBody: [{ partId: 'h', type: 'text/html' }],
attachments: [
{ blobId, type: 'text/calendar; method=PUBLISH; charset=utf-8', name: msg.icsFilename, disposition: 'attachment' },
],
},
},
},
'e',
],
[
'EmailSubmission/set',
{
accountId,
create: {
sub: {
emailId: '#msg',
identityId,
envelope: { mailFrom: { email: access.email }, rcptTo: [{ email: msg.to }] },
},
},
},
's',
],
],
)
const setRes = resp.find((r) => r[0] === 'Email/set')?.[1]
if (!setRes?.created?.msg) throw new Error(`Email/set failed: ${JSON.stringify(setRes?.notCreated ?? resp)}`)
const subRes = resp.find((r) => r[0] === 'EmailSubmission/set')?.[1]
if (!subRes?.created?.sub) throw new Error(`EmailSubmission/set failed: ${JSON.stringify(subRes?.notCreated ?? resp)}`)
}
private async call(apiUrl: string, auth: string, using: string[], methodCalls: any[]): Promise<MethodResponse[]> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
})
const text = await res.text()
if (!res.ok) throw new Error(`JMAP ${res.status}: ${text.slice(0, 200)}`)
const json = JSON.parse(text) as { methodResponses?: MethodResponse[] }
if (!json.methodResponses) throw new Error(`JMAP error: ${text.slice(0, 200)}`)
return json.methodResponses
}
}
@@ -0,0 +1,99 @@
import {
IsEnum,
IsHexColor,
IsInt,
IsMongoId,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator'
import type { LocationType } from '../../../schemas/event-type.schema.js'
const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
const LOCATIONS: LocationType[] = ['jitsi', 'phone', 'in_person', 'custom']
export class CreateEventTypeDto {
@IsString()
@Matches(SLUG, { message: 'slug must be lowercase, 2-40 chars, hyphen-separated' })
slug!: string
@IsString()
@IsNotEmpty({ message: 'title is required' })
@MaxLength(140)
title!: string
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string
@IsInt()
@Min(1)
@Max(1440)
durationMinutes!: number
@IsOptional()
@IsInt()
@Min(1)
@Max(240)
slotIntervalMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
@Max(720)
bufferBeforeMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
@Max(720)
bufferAfterMinutes?: number
@IsOptional()
@IsInt()
@Min(0)
minimumNoticeMinutes?: number
@IsOptional()
@IsInt()
@Min(1)
@Max(730)
maximumDaysInFuture?: number
@IsMongoId()
availabilityScheduleId!: string
@IsOptional()
@IsEnum(LOCATIONS)
locationType?: LocationType
@IsOptional()
@IsString()
@MaxLength(500)
locationDetails?: string
@IsOptional()
@IsHexColor()
color?: string
}
// All fields optional for PATCH. (Avoids a mapped-types dependency.)
export class UpdateEventTypeDto {
@IsOptional() @IsString() @MaxLength(140) title?: string
@IsOptional() @IsString() @MaxLength(2000) description?: string
@IsOptional() @IsInt() @Min(1) @Max(1440) durationMinutes?: number
@IsOptional() @IsInt() @Min(1) @Max(240) slotIntervalMinutes?: number
@IsOptional() @IsInt() @Min(0) @Max(720) bufferBeforeMinutes?: number
@IsOptional() @IsInt() @Min(0) @Max(720) bufferAfterMinutes?: number
@IsOptional() @IsInt() @Min(0) minimumNoticeMinutes?: number
@IsOptional() @IsInt() @Min(1) @Max(730) maximumDaysInFuture?: number
@IsOptional() @IsMongoId() availabilityScheduleId?: string
@IsOptional() @IsEnum(LOCATIONS) locationType?: LocationType
@IsOptional() @IsString() @MaxLength(500) locationDetails?: string
@IsOptional() @IsHexColor() color?: string
}
@@ -0,0 +1,72 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { EventType, EventTypeDocument, LocationType } from '../../schemas/event-type.schema.js'
export interface EventTypeInput {
slug: string
title: string
description?: string
durationMinutes: number
slotIntervalMinutes?: number
bufferBeforeMinutes?: number
bufferAfterMinutes?: number
minimumNoticeMinutes?: number
maximumDaysInFuture?: number
availabilityScheduleId: string
locationType?: LocationType
locationDetails?: string
color?: string
}
@Injectable()
export class EventTypesService {
constructor(@InjectModel(EventType.name) private readonly model: Model<EventTypeDocument>) {}
list(tenantId: Types.ObjectId, hostId: Types.ObjectId): Promise<EventTypeDocument[]> {
return this.model.find({ tenantId, hostId }).sort({ title: 1 }).exec()
}
async create(tenantId: Types.ObjectId, hostId: Types.ObjectId, input: EventTypeInput): Promise<EventTypeDocument> {
try {
return await this.model.create({
tenantId,
hostId,
...input,
availabilityScheduleId: new Types.ObjectId(input.availabilityScheduleId),
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('An event type with that slug already exists for this host.')
throw err
}
}
async get(tenantId: Types.ObjectId, id: string): Promise<EventTypeDocument> {
const doc = await this.model.findOne({ _id: id, tenantId }).exec()
if (!doc) throw new NotFoundException('Event type not found')
return doc
}
// Public resolution by (tenant, host, slug). Only active types are bookable.
async getActiveBySlug(tenantId: Types.ObjectId, hostId: Types.ObjectId, slug: string): Promise<EventTypeDocument> {
const doc = await this.model.findOne({ tenantId, hostId, slug, isActive: true }).exec()
if (!doc) throw new NotFoundException('Event type not found')
return doc
}
async update(tenantId: Types.ObjectId, id: string, input: Partial<EventTypeInput>): Promise<EventTypeDocument> {
const doc = await this.get(tenantId, id)
Object.assign(doc, {
...input,
availabilityScheduleId: input.availabilityScheduleId
? new Types.ObjectId(input.availabilityScheduleId)
: doc.availabilityScheduleId,
})
return doc.save()
}
async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
const res = await this.model.deleteOne({ _id: id, tenantId }).exec()
if (res.deletedCount === 0) throw new NotFoundException('Event type not found')
}
}
@@ -0,0 +1,25 @@
import { IsBoolean, IsMongoId, IsString, Matches, MaxLength } from 'class-validator'
const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class CreateHostDto {
// The workspace user to make bookable. Their mailbox, OIDC subject and display
// name are resolved server-side from this id — the client never supplies them.
@IsMongoId()
userId!: string
@IsString()
@Matches(SLUG, { message: 'slug must be lowercase, 2-40 chars, hyphen-separated' })
slug!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
}
export class SetHostActiveDto {
@IsBoolean()
isActive!: boolean
}
@@ -0,0 +1,119 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { StalwartClient } from '../../integrations/stalwart.client.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { User, UserDocument } from '../../schemas/user.schema.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
export interface CreateHostInput {
// Mongo id of the workspace user to make bookable. Mailbox, OIDC subject and
// display name are derived from this user, never supplied by the caller.
userId: string
slug: string
timezone: string
}
// Manages bookable hosts. Making a user bookable auto-provisions calendar access
// (app password) and discovers the host's default calendar — no user-facing
// "connect calendar" step. This is the integration coherence that is the product
// moat (CLAUDE.md §rationale).
@Injectable()
export class HostsService {
private readonly logger = new Logger(HostsService.name)
constructor(
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly stalwart: StalwartClient,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
) {}
async create(tenantId: Types.ObjectId, input: CreateHostInput): Promise<HostDocument> {
// Resolve the user (must belong to this tenant) and derive their identity.
const user = await this.userModel.findOne({ _id: input.userId, tenantIds: tenantId }).exec()
if (!user) throw new BadRequestException('User not found in this tenant.')
if (!user.mailboxAddress) {
throw new BadRequestException(`${user.name} has no workspace mailbox — only users with a mailbox can be hosts.`)
}
const email = user.mailboxAddress
// Prefer the account id captured at user provisioning; fall back to a lookup.
const accountId = user.stalwartAccountId ?? (await this.stalwart.findAccountIdByEmail(email))
if (!accountId) {
throw new BadRequestException(`No Stalwart mailbox found for ${email} — provision the mailbox first.`)
}
let host: HostDocument
try {
host = await this.hostModel.create({
tenantId,
authentikUserId: user.authentikSubjectId,
email,
displayName: user.name,
slug: input.slug,
timezone: input.timezone,
stalwartAccountId: accountId,
busyCalendarIds: [],
isActive: true,
})
} catch (err: any) {
if (err?.code === 11000) throw new ConflictException('A host with that slug or user already exists in this tenant.')
throw err
}
// Provision the credential, then discover + persist the default calendar.
try {
const access = await this.provisioner.provisionForHost(host)
const calendars = await this.gateway.listCalendars(access)
const defaultCalendarId = calendars[0]?.id
if (defaultCalendarId) {
host.defaultCalendarId = defaultCalendarId
host.busyCalendarIds = [defaultCalendarId]
await host.save()
} else {
this.logger.warn(`Host ${email} has no calendar to write to.`)
}
} catch (err) {
// Roll back the host row so a failed provisioning doesn't leave a half-host.
await this.hostModel.deleteOne({ _id: host._id }).exec()
throw new BadRequestException(`Could not provision calendar access for ${email}: ${(err as Error).message}`)
}
return host
}
list(tenantId: Types.ObjectId): Promise<HostDocument[]> {
return this.hostModel.find({ tenantId }).sort({ displayName: 1 }).exec()
}
async getById(tenantId: Types.ObjectId, hostId: string): Promise<HostDocument> {
const host = await this.hostModel.findOne({ _id: hostId, tenantId }).exec()
if (!host) throw new NotFoundException('Host not found')
return host
}
async getBySlug(tenantId: Types.ObjectId, slug: string): Promise<HostDocument> {
const host = await this.hostModel.findOne({ tenantId, slug }).exec()
if (!host) throw new NotFoundException('Host not found')
return host
}
async setActive(tenantId: Types.ObjectId, hostId: string, isActive: boolean): Promise<HostDocument> {
const host = await this.getById(tenantId, hostId)
host.isActive = isActive
return host.save()
}
async rotateCredential(tenantId: Types.ObjectId, hostId: string): Promise<void> {
const host = await this.getById(tenantId, hostId)
await this.provisioner.provisionForHost(host)
}
async remove(tenantId: Types.ObjectId, hostId: string): Promise<void> {
const host = await this.getById(tenantId, hostId)
await this.provisioner.deprovisionForHost(host._id, host.stalwartAccountId)
await this.hostModel.deleteOne({ _id: host._id }).exec()
}
}
@@ -0,0 +1,63 @@
import { IsEmail, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'
// IANA timezone sanity check (Area/Location, optionally a third segment). Not a
// full tz-database membership test — Luxon rejects unknown zones downstream.
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
export class SlotsQueryDto {
@IsISO8601()
from!: string
@IsISO8601()
to!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
timezone!: string
}
export class CreateHoldDto {
@IsISO8601()
startUtc!: string
}
export class CreateBookingDto {
@IsISO8601()
startUtc!: string
@IsString()
@MaxLength(120)
attendeeName!: string
@IsEmail()
@MaxLength(254)
attendeeEmail!: string
@IsString()
@MaxLength(64)
@Matches(TZ, { message: 'attendeeTimezone must be an IANA zone like Europe/Copenhagen' })
attendeeTimezone!: string
@IsOptional()
@IsString()
@MaxLength(2000)
attendeeNotes?: string
@IsOptional()
@IsString()
@MaxLength(64)
holdId?: string
}
export class CancelBookingDto {
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string
}
export class RescheduleBookingDto {
@IsISO8601()
startUtc!: string
}
@@ -0,0 +1,156 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
import { TenantsService } from '../../tenants/tenants.service.js'
import type { BookingContext } from '../bookings/bookings.service.js'
import { BookingsService } from '../bookings/bookings.service.js'
import { EventTypesService } from '../event-types/event-types.service.js'
import { HostsService } from '../hosts/hosts.service.js'
import { SlotService } from '../slots/slot.service.js'
import { BookingDocument } from '../../schemas/booking.schema.js'
import {
CancelBookingDto,
CreateBookingDto,
CreateHoldDto,
RescheduleBookingDto,
SlotsQueryDto,
} from './dto/public-dtos.js'
import { PublicSchedulingService } from './public-scheduling.service.js'
// Public booking surface — unauthenticated, served to the booking.dezky.eu app.
// Rate-limited per-IP (anti-abuse). Returns UTC instants; the client renders in
// the visitor's tz. No internal ids or host PII beyond display name leak out.
@Controller('api/v1/public')
@UseGuards(ThrottlerGuard)
export class PublicSchedulingController {
constructor(
private readonly publicSvc: PublicSchedulingService,
private readonly slots: SlotService,
private readonly bookings: BookingsService,
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService,
) {}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug')
async info(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
return this.publicSvc.publicInfo(ctx)
}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug/slots')
async availableSlots(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Query() q: SlotsQueryDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const slots = await this.slots.availableSlots(ctx.host, ctx.eventType, new Date(q.from), new Date(q.to))
return {
timezone: q.timezone,
durationMinutes: ctx.eventType.durationMinutes,
slots: slots.map((s) => ({ startUtc: s.startUtc.toISOString(), endUtc: s.endUtc.toISOString() })),
}
}
// Tighter limit on the write endpoints than the read default.
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post(':tenantSlug/:hostSlug/:eventTypeSlug/holds')
async createHold(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateHoldDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc))
return { holdId, expiresAt: expiresAt.toISOString() }
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post(':tenantSlug/:hostSlug/:eventTypeSlug/bookings')
async createBooking(
@Param('tenantSlug') tenantSlug: string,
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateBookingDto,
) {
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const booking = await this.bookings.confirm(ctx, {
startUtc: new Date(dto.startUtc),
attendeeName: dto.attendeeName,
attendeeEmail: dto.attendeeEmail,
attendeeTimezone: dto.attendeeTimezone,
attendeeNotes: dto.attendeeNotes,
holdId: dto.holdId,
})
return this.publicBooking(booking, ctx)
}
@Get('bookings/:manageToken')
async manageView(@Param('manageToken') manageToken: string) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
return this.publicBooking(booking, ctx)
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('bookings/:manageToken/cancel')
async cancel(@Param('manageToken') manageToken: string, @Body() dto: CancelBookingDto) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
const updated = await this.bookings.cancel(manageToken, dto.reason, ctx)
return this.publicBooking(updated, ctx)
}
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('bookings/:manageToken/reschedule')
async reschedule(@Param('manageToken') manageToken: string, @Body() dto: RescheduleBookingDto) {
const booking = await this.bookings.getByManageToken(manageToken)
const ctx = await this.contextFromBooking(booking)
const fresh = await this.bookings.reschedule(manageToken, new Date(dto.startUtc), ctx)
return this.publicBooking(fresh, ctx)
}
// Rebuild the booking context (tenant + host + event type) from a stored booking.
private async contextFromBooking(booking: BookingDocument): Promise<BookingContext> {
const tenant = await this.tenants.findOneById(booking.tenantId)
const host = await this.hosts.getById(tenant._id, String(booking.hostId))
const eventType = await this.eventTypes.get(tenant._id, String(booking.eventTypeId))
return {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
}
private publicBooking(booking: BookingDocument, ctx: BookingContext) {
return {
manageToken: booking.manageToken,
status: booking.status,
startUtc: booking.startUtc.toISOString(),
endUtc: booking.endUtc.toISOString(),
attendeeName: booking.attendeeName,
attendeeEmail: booking.attendeeEmail,
attendeeTimezone: booking.attendeeTimezone,
attendeeNotes: booking.attendeeNotes ?? null,
locationType: booking.locationType ?? null,
locationUrl: booking.locationUrl ?? null,
branding: {
tenantSlug: ctx.tenant.slug,
name: ctx.tenant.name,
brandColor: ctx.tenant.brandColor ?? null,
},
host: { slug: ctx.host.slug, displayName: ctx.host.displayName, timezone: ctx.host.timezone },
eventType: {
slug: ctx.eventType.slug,
title: ctx.eventType.title,
durationMinutes: ctx.eventType.durationMinutes,
},
}
}
}
@@ -0,0 +1,52 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { TenantsService } from '../../tenants/tenants.service.js'
import { EventTypesService } from '../event-types/event-types.service.js'
import { HostsService } from '../hosts/hosts.service.js'
import type { BookingContext } from '../bookings/bookings.service.js'
// Resolves the unauthenticated (tenantSlug, hostSlug, eventTypeSlug) triple into
// a fully-loaded BookingContext, and shapes the public-facing info payload (no
// internal ids, no PII). Only active hosts + event types are bookable.
@Injectable()
export class PublicSchedulingService {
constructor(
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService,
) {}
async resolveContext(tenantSlug: string, hostSlug: string, eventTypeSlug: string): Promise<BookingContext> {
const tenant = await this.tenants.findOneBySlug(tenantSlug)
const host = await this.hosts.getBySlug(tenant._id, hostSlug)
if (!host.isActive) throw new BadRequestException('This booking page is not available.')
const eventType = await this.eventTypes.getActiveBySlug(tenant._id, host._id, eventTypeSlug)
return {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
}
// Public event-type + host info + tenant branding, for the booking page header.
publicInfo(ctx: BookingContext) {
return {
branding: {
tenantSlug: ctx.tenant.slug,
name: ctx.tenant.name,
brandColor: ctx.tenant.brandColor ?? null,
},
host: {
slug: ctx.host.slug,
displayName: ctx.host.displayName,
timezone: ctx.host.timezone,
},
eventType: {
slug: ctx.eventType.slug,
title: ctx.eventType.title,
description: ctx.eventType.description ?? null,
durationMinutes: ctx.eventType.durationMinutes,
locationType: ctx.eventType.locationType,
},
}
}
}
@@ -0,0 +1,207 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common'
import { Types } from 'mongoose'
import { ActorService } from '../auth/actor.service.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { AvailabilityService } from './availability/availability.service.js'
import { CreateAvailabilityDto, UpdateAvailabilityDto } from './availability/dto/availability-dtos.js'
import { BookingsService, type BookingContext } from './bookings/bookings.service.js'
import { CancelBookingDto, RescheduleBookingDto } from './public/dto/public-dtos.js'
import { CreateEventTypeDto, UpdateEventTypeDto } from './event-types/dto/event-type-dtos.js'
import { EventTypesService } from './event-types/event-types.service.js'
import { CreateHostDto, SetHostActiveDto } from './hosts/dto/create-host.dto.js'
import { HostsService } from './hosts/hosts.service.js'
// Authenticated host/admin scheduling config (OIDC via Authentik). Tenant-scoped
// and gated exactly like the rest of platform-api: platformAdmin OR a member of
// the tenant. Mounted under /api/v1 per the scheduling routing decision.
@Controller('api/v1/tenants/:slug/scheduling')
@UseGuards(JwtAuthGuard)
export class SchedulingAdminController {
constructor(
private readonly actor: ActorService,
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly availability: AvailabilityService,
private readonly eventTypes: EventTypesService,
private readonly bookings: BookingsService,
) {}
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return tenant._id
}
// ── Hosts ──
@Get('hosts')
async listHosts(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.list(await this.gate(slug, jwt))
}
@Post('hosts')
async createHost(@Param('slug') slug: string, @Body() dto: CreateHostDto, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.create(await this.gate(slug, jwt), dto)
}
@Get('hosts/:hostId')
async getHost(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.hosts.getById(await this.gate(slug, jwt), hostId)
}
@Patch('hosts/:hostId/active')
async setHostActive(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: SetHostActiveDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.hosts.setActive(await this.gate(slug, jwt), hostId, dto.isActive)
}
@Post('hosts/:hostId/rotate-credential')
@HttpCode(204)
async rotateCredential(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.hosts.rotateCredential(await this.gate(slug, jwt), hostId)
}
@Delete('hosts/:hostId')
@HttpCode(204)
async removeHost(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.hosts.remove(await this.gate(slug, jwt), hostId)
}
// ── Availability schedules ──
@Get('hosts/:hostId/availability')
async listAvailability(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenantId = await this.gate(slug, jwt)
return this.availability.list(tenantId, new Types.ObjectId(hostId))
}
@Post('hosts/:hostId/availability')
async createAvailability(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: CreateAvailabilityDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.availability.create(tenantId, new Types.ObjectId(hostId), dto)
}
@Patch('availability/:id')
async updateAvailability(
@Param('slug') slug: string,
@Param('id') id: string,
@Body() dto: UpdateAvailabilityDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.availability.update(await this.gate(slug, jwt), id, dto)
}
@Delete('availability/:id')
@HttpCode(204)
async removeAvailability(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.availability.remove(await this.gate(slug, jwt), id)
}
// ── Event types ──
@Get('hosts/:hostId/event-types')
async listEventTypes(@Param('slug') slug: string, @Param('hostId') hostId: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenantId = await this.gate(slug, jwt)
return this.eventTypes.list(tenantId, new Types.ObjectId(hostId))
}
@Post('hosts/:hostId/event-types')
async createEventType(
@Param('slug') slug: string,
@Param('hostId') hostId: string,
@Body() dto: CreateEventTypeDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.eventTypes.create(tenantId, new Types.ObjectId(hostId), dto)
}
@Patch('event-types/:id')
async updateEventType(
@Param('slug') slug: string,
@Param('id') id: string,
@Body() dto: UpdateEventTypeDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
return this.eventTypes.update(await this.gate(slug, jwt), id, dto)
}
@Delete('event-types/:id')
@HttpCode(204)
async removeEventType(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
await this.eventTypes.remove(await this.gate(slug, jwt), id)
}
// ── Bookings ──
@Get('bookings')
async listBookings(
@Param('slug') slug: string,
@Query('hostId') hostId: string | undefined,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
return this.bookings.listForTenant(tenantId, hostId ? new Types.ObjectId(hostId) : undefined)
}
@Post('bookings/:bookingId/cancel')
async cancelBooking(
@Param('slug') slug: string,
@Param('bookingId') bookingId: string,
@Body() dto: CancelBookingDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
const { booking, ctx } = await this.resolveBookingCtx(tenantId, bookingId)
return this.bookings.cancelResolved(booking, dto.reason, ctx)
}
@Post('bookings/:bookingId/reschedule')
async rescheduleBooking(
@Param('slug') slug: string,
@Param('bookingId') bookingId: string,
@Body() dto: RescheduleBookingDto,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenantId = await this.gate(slug, jwt)
const { booking, ctx } = await this.resolveBookingCtx(tenantId, bookingId)
return this.bookings.rescheduleResolved(booking, new Date(dto.startUtc), ctx)
}
// Load a tenant-scoped booking + its full booking context (tenant + host + event type).
private async resolveBookingCtx(tenantId: Types.ObjectId, bookingId: string) {
const booking = await this.bookings.getForTenant(tenantId, bookingId)
const tenant = await this.tenants.findOneById(tenantId)
const host = await this.hosts.getById(tenantId, String(booking.hostId))
const eventType = await this.eventTypes.get(tenantId, String(booking.eventTypeId))
const ctx: BookingContext = {
tenant: { _id: tenant._id, slug: tenant.slug, name: tenant.name, brandColor: tenant.brandColor },
host,
eventType,
}
return { booking, ctx }
}
}
@@ -0,0 +1,57 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ThrottlerModule } from '@nestjs/throttler'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { AvailabilitySchedule, AvailabilityScheduleSchema } from '../schemas/availability-schedule.schema.js'
import { Booking, BookingSchema } from '../schemas/booking.schema.js'
import { EventType, EventTypeSchema } from '../schemas/event-type.schema.js'
import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { AvailabilityService } from './availability/availability.service.js'
import { BookingsService } from './bookings/bookings.service.js'
import { JmapMailer } from './email/jmap-mailer.service.js'
import { EventTypesService } from './event-types/event-types.service.js'
import { HostsService } from './hosts/hosts.service.js'
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
import { PublicSchedulingService } from './public/public-scheduling.service.js'
import { SchedulingAdminController } from './scheduling-admin.controller.js'
import { SlotService } from './slots/slot.service.js'
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.js'
// dezky Scheduling — Calendly-style booking on top of Stalwart calendars. Public
// pages (booking.dezky.eu) hit the unauthenticated /api/v1/public routes; host
// config sits behind the workspace-portal OIDC. The Stalwart integration is
// isolated in StalwartCalendarModule so it can be extracted later.
@Module({
imports: [
MongooseModule.forFeature([
{ name: Host.name, schema: HostSchema },
{ name: AvailabilitySchedule.name, schema: AvailabilityScheduleSchema },
{ name: EventType.name, schema: EventTypeSchema },
{ name: Booking.name, schema: BookingSchema },
{ name: SlotLock.name, schema: SlotLockSchema },
{ name: User.name, schema: UserSchema },
]),
// Per-IP rate limiting for the public booking endpoints (default read limit;
// write endpoints tighten it via @Throttle).
ThrottlerModule.forRoot({ throttlers: [{ name: 'default', ttl: 60_000, limit: 60 }] }),
AuthModule,
TenantsModule,
IntegrationsModule, // StalwartClient — host→account lookup during onboarding
StalwartCalendarModule,
],
controllers: [SchedulingAdminController, PublicSchedulingController],
providers: [
HostsService,
AvailabilityService,
EventTypesService,
SlotService,
BookingsService,
PublicSchedulingService,
JmapMailer,
],
})
export class SchedulingModule {}
@@ -0,0 +1,146 @@
import { DateTime } from 'luxon'
import type { WeeklyRule } from '../../schemas/availability-schedule.schema.js'
import { computeSlots, SlotComputeInput } from './slot-computer.js'
// Pure slot-math tests, with a focus on the DST boundary (DoD: "display is
// correct across a DST boundary"). All assertions compute the expected UTC with
// Luxon so they stay correct regardless of the machine's local tz.
const TZ = 'Europe/Copenhagen'
// 09:0017:00 every day, in minutes from local midnight.
const allWeek = (start = 540, end = 1020): WeeklyRule[] =>
Array.from({ length: 7 }, (_, dow) => ({ dayOfWeek: dow, intervals: [{ startMinute: start, endMinute: end }] }))
function baseInput(over: Partial<SlotComputeInput> = {}): SlotComputeInput {
return {
durationMinutes: 60,
slotIntervalMinutes: 60,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
minimumNoticeMinutes: 0,
maximumDaysInFuture: 365,
scheduleTimezone: TZ,
weeklyRules: allWeek(),
dateOverrides: [],
busy: [],
now: new Date('2026-06-01T00:00:00Z'),
fromUtc: new Date('2026-06-01T00:00:00Z'),
toUtc: new Date('2026-06-02T00:00:00Z'),
...over,
}
}
const iso = (s: string, tz = TZ) => DateTime.fromISO(s, { zone: tz }).toUTC().toISO()
const isoSet = (slots: { startUtc: Date }[]) => slots.map((s) => s.startUtc.toISOString().replace('.000Z', 'Z'))
describe('computeSlots', () => {
it('generates one slot per step within the daily window', () => {
// 09:0017:00, 60-min slots → 09,10,11,12,13,14,15,16 = 8 slots.
const slots = computeSlots(baseInput())
expect(slots).toHaveLength(8)
expect(slots[0].startUtc.toISOString()).toBe(new Date(iso('2026-06-01T09:00')!).toISOString())
expect(slots[7].startUtc.toISOString()).toBe(new Date(iso('2026-06-01T16:00')!).toISOString())
})
it('respects slotIntervalMinutes independent of duration', () => {
// 30-min slots, 60-min duration, 09:0011:00 window → 09:00, 09:30, 10:00.
const slots = computeSlots(
baseInput({ durationMinutes: 60, slotIntervalMinutes: 30, weeklyRules: allWeek(540, 660) }),
)
expect(slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('HH:mm'))).toEqual([
'09:00', '09:30', '10:00',
])
})
it('drops slots inside the minimum-notice window', () => {
const now = new Date('2026-06-01T09:30:00Z') // 11:30 local CEST
const slots = computeSlots(baseInput({ now, minimumNoticeMinutes: 120 }))
// Earliest allowed = 11:30Z. First remaining slot is 12:00 local = 10:00Z...
// 09:30Z + 120min = 11:30Z; first slot start >= 11:30Z is 14:00 local (12:00Z).
expect(slots[0].startUtc.toISOString()).toBe(new Date('2026-06-01T12:00:00Z').toISOString())
})
it('removes slots overlapping a busy interval expanded by buffers', () => {
// Busy 10:0010:30 local (08:0008:30Z) with 30-min buffers blocks 09:00..11:00.
const busy = [{ startUtc: new Date(iso('2026-06-01T10:00')!), endUtc: new Date(iso('2026-06-01T10:30')!) }]
const slots = computeSlots(baseInput({ bufferBeforeMinutes: 30, bufferAfterMinutes: 30, busy }))
const local = slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('HH:mm'))
// 10:00 overlaps directly; 09:00's +30 after-buffer (ends 08:30Z) overlaps the
// busy block. 11:00's -30 before-buffer ends exactly at 08:30Z — touching, not
// overlapping — so it stays available.
expect(local).not.toContain('10:00')
expect(local).not.toContain('09:00')
expect(local).toContain('11:00')
expect(local).toContain('12:00')
})
it('honours the booking horizon (maximumDaysInFuture)', () => {
const slots = computeSlots(
baseInput({
maximumDaysInFuture: 1,
fromUtc: new Date('2026-06-01T00:00:00Z'),
toUtc: new Date('2026-06-10T00:00:00Z'),
}),
)
// now=2026-06-01T00:00Z, horizon +1 day = 2026-06-02T00:00Z. Only 1st's slots.
const days = new Set(slots.map((s) => DateTime.fromJSDate(s.startUtc).setZone(TZ).toFormat('yyyy-MM-dd')))
expect([...days]).toEqual(['2026-06-01'])
})
// ── DST ────────────────────────────────────────────────────────────────────
it('maps the same local time to different UTC across the spring-forward boundary', () => {
// Copenhagen DST starts 2026-03-29. 09:00 local: 28th = CET (+1) → 08:00Z;
// 30th = CEST (+2) → 07:00Z.
const slots = computeSlots(
baseInput({
durationMinutes: 60,
slotIntervalMinutes: 60,
weeklyRules: allWeek(540, 600), // 09:0010:00
now: new Date('2026-03-01T00:00:00Z'),
fromUtc: new Date('2026-03-28T00:00:00Z'),
toUtc: new Date('2026-03-31T00:00:00Z'),
}),
)
const set = isoSet(slots)
expect(set).toContain('2026-03-28T08:00:00Z') // before DST (+1)
expect(set).toContain('2026-03-30T07:00:00Z') // after DST (+2)
})
it('never offers a slot at a non-existent spring-forward local time', () => {
// The gap is 02:00→03:00 local on 2026-03-29. Offer 02:0003:00 that day;
// 30-min slots at 02:00 and 02:30 do not exist and must be dropped.
const slots = computeSlots(
baseInput({
durationMinutes: 30,
slotIntervalMinutes: 30,
weeklyRules: [],
dateOverrides: [{ date: '2026-03-29', isUnavailable: false, intervals: [{ startMinute: 120, endMinute: 180 }] }],
now: new Date('2026-03-01T00:00:00Z'),
fromUtc: new Date('2026-03-29T00:00:00Z'),
toUtc: new Date('2026-03-30T00:00:00Z'),
}),
)
expect(slots).toHaveLength(0)
})
it('applies date overrides: unavailable day yields no slots', () => {
const slots = computeSlots(
baseInput({
dateOverrides: [{ date: '2026-06-01', isUnavailable: true, intervals: [] }],
}),
)
expect(slots).toHaveLength(0)
})
it('date override intervals replace the weekly rule for that date', () => {
const slots = computeSlots(
baseInput({
weeklyRules: allWeek(540, 1020), // 0917 normally
dateOverrides: [{ date: '2026-06-01', isUnavailable: false, intervals: [{ startMinute: 780, endMinute: 840 }] }], // 13:0014:00
}),
)
expect(slots).toHaveLength(1)
expect(DateTime.fromJSDate(slots[0].startUtc).setZone(TZ).toFormat('HH:mm')).toBe('13:00')
})
})
@@ -0,0 +1,119 @@
import { DateTime } from 'luxon'
import type { DateOverride, MinuteInterval, WeeklyRule } from '../../schemas/availability-schedule.schema.js'
// Pure slot computation (§8.1). No I/O, no Mongoose — takes the rules + the known
// busy intervals and returns free UTC slots. All DST/offset math goes through
// Luxon; there is no manual offset arithmetic anywhere. Unit-tested across a
// Europe/Copenhagen DST boundary.
export interface SlotComputeInput {
durationMinutes: number
slotIntervalMinutes: number
bufferBeforeMinutes: number
bufferAfterMinutes: number
minimumNoticeMinutes: number
maximumDaysInFuture: number
scheduleTimezone: string // IANA — availability is authored in this zone
weeklyRules: WeeklyRule[]
dateOverrides: DateOverride[]
// Already-busy UTC intervals (calendar free/busy + confirmed bookings + live holds).
busy: Array<{ startUtc: Date; endUtc: Date }>
now: Date
fromUtc: Date // requested window start
toUtc: Date // requested window end (exclusive)
}
export interface ComputedSlot {
startUtc: Date
endUtc: Date
}
export function computeSlots(input: SlotComputeInput): ComputedSlot[] {
const {
durationMinutes, slotIntervalMinutes, bufferBeforeMinutes, bufferAfterMinutes,
minimumNoticeMinutes, maximumDaysInFuture, scheduleTimezone,
weeklyRules, dateOverrides, busy, now, fromUtc, toUtc,
} = input
const zone = scheduleTimezone
const earliest = new Date(Math.max(now.getTime() + minimumNoticeMinutes * 60_000, fromUtc.getTime()))
const horizon = new Date(now.getTime() + maximumDaysInFuture * 86_400_000)
const latest = new Date(Math.min(horizon.getTime(), toUtc.getTime()))
if (earliest >= latest) return []
const overridesByDate = new Map(dateOverrides.map((o) => [o.date, o]))
// Walk local calendar dates in the schedule zone from `earliest` to `latest`.
// Start one day early so a slot near a tz/day boundary isn't missed.
let cursor = DateTime.fromJSDate(earliest, { zone }).startOf('day').minus({ days: 1 })
const end = DateTime.fromJSDate(latest, { zone }).endOf('day')
const slots: ComputedSlot[] = []
while (cursor <= end) {
const isoDate = cursor.toFormat('yyyy-MM-dd')
const intervals = intervalsForDate(cursor, isoDate, weeklyRules, overridesByDate)
for (const intv of intervals) {
for (
let m = intv.startMinute;
m + durationMinutes <= intv.endMinute;
m += slotIntervalMinutes
) {
const hour = Math.floor(m / 60)
const minute = m % 60
const startLocal = cursor.set({ hour, minute, second: 0, millisecond: 0 })
// Non-existent local time (spring-forward gap): Luxon silently shifts the
// wall clock forward rather than flagging invalid, so detect the gap by a
// round-trip check and skip — we never offer a slot at a time that didn't
// exist locally.
if (!startLocal.isValid || startLocal.hour !== hour || startLocal.minute !== minute) continue
const startUtc = startLocal.toUTC().toJSDate()
const endUtc = new Date(startUtc.getTime() + durationMinutes * 60_000)
if (startUtc < earliest || startUtc >= latest) continue
if (overlapsBusy(startUtc, endUtc, bufferBeforeMinutes, bufferAfterMinutes, busy)) continue
slots.push({ startUtc, endUtc })
}
}
cursor = cursor.plus({ days: 1 })
}
// De-dupe (a fall-back DST hour can yield two identical local→UTC mappings) and sort.
const seen = new Set<number>()
return slots
.filter((s) => (seen.has(s.startUtc.getTime()) ? false : seen.add(s.startUtc.getTime())))
.sort((a, b) => a.startUtc.getTime() - b.startUtc.getTime())
}
function intervalsForDate(
date: DateTime,
isoDate: string,
weeklyRules: WeeklyRule[],
overrides: Map<string, DateOverride>,
): MinuteInterval[] {
const override = overrides.get(isoDate)
if (override) {
if (override.isUnavailable) return []
return override.intervals ?? []
}
const jsDow = date.weekday % 7 // luxon 1=Mon..7=Sun → 0=Sun..6=Sat
return weeklyRules.filter((r) => r.dayOfWeek === jsDow).flatMap((r) => r.intervals ?? [])
}
// A candidate conflicts if the slot, padded by its before/after buffers, overlaps
// any busy interval.
function overlapsBusy(
startUtc: Date,
endUtc: Date,
bufferBefore: number,
bufferAfter: number,
busy: Array<{ startUtc: Date; endUtc: Date }>,
): boolean {
const padStart = startUtc.getTime() - bufferBefore * 60_000
const padEnd = endUtc.getTime() + bufferAfter * 60_000
for (const b of busy) {
if (padStart < b.endUtc.getTime() && padEnd > b.startUtc.getTime()) return true
}
return false
}
@@ -0,0 +1,87 @@
import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AvailabilitySchedule, AvailabilityScheduleDocument } from '../../schemas/availability-schedule.schema.js'
import { Booking, BookingDocument } from '../../schemas/booking.schema.js'
import { EventTypeDocument } from '../../schemas/event-type.schema.js'
import { HostDocument } from '../../schemas/scheduling-host.schema.js'
import { SlotLock, SlotLockDocument } from '../../schemas/slot-lock.schema.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
import { computeSlots, ComputedSlot } from './slot-computer.js'
@Injectable()
export class SlotService {
constructor(
@InjectModel(AvailabilitySchedule.name) private readonly scheduleModel: Model<AvailabilityScheduleDocument>,
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@InjectModel(SlotLock.name) private readonly lockModel: Model<SlotLockDocument>,
private readonly provisioner: CredentialProvisioner,
private readonly gateway: JmapCalendarGateway,
) {}
// Free UTC slots for a host+event-type within [fromUtc, toUtc). Fails CLOSED on
// calendar errors (§9): rather than show slots we can't verify against live
// free/busy — risking a double-book — we surface 503 so the UI shows a retry.
async availableSlots(
host: HostDocument,
eventType: EventTypeDocument,
fromUtc: Date,
toUtc: Date,
now: Date = new Date(),
): Promise<ComputedSlot[]> {
const schedule = await this.scheduleModel.findById(eventType.availabilityScheduleId).exec()
if (!schedule) throw new NotFoundException('Event type has no availability schedule')
const access = await this.provisioner.resolveAccess(host)
let calendarBusy
try {
calendarBusy = await this.gateway.getBusyIntervals(access, fromUtc, toUtc)
} catch {
throw new ServiceUnavailableException('Calendar is temporarily unavailable — please retry.')
}
const [bookingBusy, lockBusy] = await Promise.all([
this.confirmedBookingIntervals(host._id, fromUtc, toUtc),
this.activeLockIntervals(host._id, fromUtc, toUtc, now),
])
return computeSlots({
durationMinutes: eventType.durationMinutes,
slotIntervalMinutes: eventType.slotIntervalMinutes,
bufferBeforeMinutes: eventType.bufferBeforeMinutes,
bufferAfterMinutes: eventType.bufferAfterMinutes,
minimumNoticeMinutes: eventType.minimumNoticeMinutes,
maximumDaysInFuture: eventType.maximumDaysInFuture,
scheduleTimezone: schedule.timezone,
weeklyRules: schedule.weeklyRules,
dateOverrides: schedule.dateOverrides,
busy: [...calendarBusy, ...bookingBusy, ...lockBusy],
now,
fromUtc,
toUtc,
})
}
private async confirmedBookingIntervals(hostId: Types.ObjectId, from: Date, to: Date) {
const bookings = await this.bookingModel
.find({ hostId, status: { $in: ['confirmed', 'pending'] }, startUtc: { $lt: to }, endUtc: { $gt: from } })
.select('startUtc endUtc')
.exec()
return bookings.map((b) => ({ startUtc: b.startUtc, endUtc: b.endUtc }))
}
private async activeLockIntervals(hostId: Types.ObjectId, from: Date, to: Date, now: Date) {
const locks = await this.lockModel
.find({
hostId,
startUtc: { $lt: to },
endUtc: { $gt: from },
$or: [{ expiresAt: null }, { expiresAt: { $gt: now } }],
})
.select('startUtc endUtc')
.exec()
return locks.map((l) => ({ startUtc: l.startUtc, endUtc: l.endUtc }))
}
}
@@ -0,0 +1,48 @@
// Transport-agnostic calendar gateway contract. JMAP is the only implementation
// today (CalDAV fallback shelved after Phase 0), but callers depend on this
// interface so the transport stays an implementation detail.
// Decrypted, ready-to-use access to one host's Stalwart calendar. Built by the
// CredentialProvisioner from the Host + its encrypted StalwartCredential.
export interface HostCalendarAccess {
email: string // host mailbox address (HTTP Basic user)
secret: string // decrypted app password (HTTP Basic pass) — never logged
jmapSessionUrl: string // .well-known/jmap on the internal Stalwart hostname
defaultCalendarId?: string
busyCalendarIds: string[]
}
export interface Interval {
startUtc: Date
endUtc: Date
}
export interface CalendarRef {
id: string
name: string
}
// The event we write when a booking is confirmed. Times are UTC; hostTimezone is
// the IANA zone the JSCalendar local start/end are expressed in.
export interface BookingEvent {
uid: string // client-generated, idempotent across retries
title: string
description?: string
startUtc: Date
endUtc: Date
hostTimezone: string
location?: string
hostEmail: string
attendeeName: string
attendeeEmail: string
}
export interface CalendarGateway {
listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]>
// Busy intervals within [fromUtc, toUtc), recurrence already expanded, in UTC.
getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]>
// Returns the server-assigned event id (distinct from the UID).
createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }>
deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void>
validateCredential(access: HostCalendarAccess): Promise<boolean>
}
@@ -0,0 +1,102 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { StalwartClient } from '../../integrations/stalwart.client.js'
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
import { StalwartCredential, StalwartCredentialDocument } from '../../schemas/stalwart-credential.schema.js'
import { CredentialCipher } from '../crypto/credential-cipher.js'
import type { HostCalendarAccess } from './calendar-gateway.types.js'
// Owns the lifecycle of a host's encrypted Stalwart calendar credential. At
// onboarding it mints an app password on-behalf via the admin JMAP and stores it
// AES-256-GCM-encrypted; at read time it decrypts into a HostCalendarAccess the
// gateway can use. No user-facing "connect calendar" step — this is the whole
// point of owning both identity and the calendar server (CLAUDE.md §rationale).
@Injectable()
export class CredentialProvisioner {
private readonly logger = new Logger(CredentialProvisioner.name)
private readonly sessionUrl: string
constructor(
private readonly stalwart: StalwartClient,
private readonly cipher: CredentialCipher,
config: ConfigService,
@InjectModel(StalwartCredential.name)
private readonly credModel: Model<StalwartCredentialDocument>,
) {
// .well-known/jmap on the internal Stalwart hostname (STALWART_API_URL).
const base = config.getOrThrow<string>('STALWART_API_URL').replace(/\/$/, '')
this.sessionUrl = `${base}/.well-known/jmap`
}
// Mint + persist (or rotate) a host's app-password credential. Destroys any
// prior app password first so we never leak orphaned credentials. Returns the
// freshly built access so the caller can immediately list calendars.
async provisionForHost(host: HostDocument): Promise<HostCalendarAccess> {
const existing = await this.credModel.findOne({ hostId: host._id }).exec()
if (existing?.appPasswordId) {
await this.stalwart
.deleteAppPassword(host.stalwartAccountId, existing.appPasswordId)
.catch((e) => this.logger.warn(`Could not remove old app password for ${host.email}: ${e.message}`))
}
const { id, secret } = await this.stalwart.createAppPassword(
host.stalwartAccountId,
`dezky-scheduling:${host.slug}`,
)
const sealed = this.cipher.seal(secret)
await this.credModel.updateOne(
{ hostId: host._id },
{
$set: {
tenantId: host.tenantId,
type: 'app_password',
encryptedSecret: sealed.encryptedSecret,
iv: sealed.iv,
authTag: sealed.authTag,
appPasswordId: id,
jmapSessionUrl: this.sessionUrl,
lastValidatedAt: new Date(),
},
},
{ upsert: true },
)
this.logger.log(`Provisioned scheduling credential for host ${host.email}`)
return {
email: host.email,
secret,
jmapSessionUrl: this.sessionUrl,
defaultCalendarId: host.defaultCalendarId,
busyCalendarIds: host.busyCalendarIds?.length ? host.busyCalendarIds : host.defaultCalendarId ? [host.defaultCalendarId] : [],
}
}
// Decrypt a host's stored credential into ready-to-use access. Throws if the
// host was never provisioned.
async resolveAccess(host: HostDocument): Promise<HostCalendarAccess> {
const cred = await this.credModel.findOne({ hostId: host._id }).exec()
if (!cred) throw new NotFoundException(`Host ${host.email} has no scheduling credential — provision first`)
const secret = this.cipher.open({ encryptedSecret: cred.encryptedSecret, iv: cred.iv, authTag: cred.authTag })
return {
email: host.email,
secret,
jmapSessionUrl: cred.jmapSessionUrl,
defaultCalendarId: host.defaultCalendarId,
busyCalendarIds: host.busyCalendarIds?.length ? host.busyCalendarIds : host.defaultCalendarId ? [host.defaultCalendarId] : [],
}
}
// Remove a host's app password from Stalwart and delete the stored credential.
async deprovisionForHost(hostId: Types.ObjectId, stalwartAccountId: string): Promise<void> {
const cred = await this.credModel.findOne({ hostId }).exec()
if (cred?.appPasswordId) {
await this.stalwart
.deleteAppPassword(stalwartAccountId, cred.appPasswordId)
.catch((e) => this.logger.warn(`Could not remove app password: ${e.message}`))
}
await this.credModel.deleteOne({ hostId }).exec()
}
}
@@ -0,0 +1,206 @@
import { Injectable, Logger } from '@nestjs/common'
import type {
BookingEvent,
CalendarGateway,
CalendarRef,
HostCalendarAccess,
Interval,
} from './calendar-gateway.types.js'
// JMAP-for-Calendars implementation against Stalwart (verified in Phase 0 — see
// reference_stalwart_calendar_jmap). Per-host auth is HTTP Basic with the host's
// app password. Free/busy uses Principal/getAvailability (server expands
// recurrences and returns UTC intervals). Bookings are written via
// CalendarEvent/set; the attendee is added with scheduleAgent:"client" so the
// server never sends an iMIP invite (dezky sends its own branded email), and the
// attendee is ALSO folded into the title/description because Stalwart's Community
// build does not persist the participants block (Phase 0 finding) — that keeps
// the booking legible to the host on their own calendar regardless.
const CORE = 'urn:ietf:params:jmap:core'
const CALENDARS = 'urn:ietf:params:jmap:calendars'
const PRINCIPALS = 'urn:ietf:params:jmap:principals'
const AVAILABILITY = 'urn:ietf:params:jmap:principals:availability'
interface JmapSession {
apiUrl: string
primaryAccounts: Record<string, string>
}
type MethodCall = [string, Record<string, unknown>, string]
type MethodResponse = [string, Record<string, any>, string]
@Injectable()
export class JmapCalendarGateway implements CalendarGateway {
private readonly logger = new Logger(JmapCalendarGateway.name)
private authHeader(access: HostCalendarAccess): string {
return `Basic ${Buffer.from(`${access.email}:${access.secret}`).toString('base64')}`
}
// Method-call endpoint, derived from the session URL so we always hit the
// internal Stalwart hostname (the session object's apiUrl may advertise the
// public Traefik FQDN, which Node's fetch can't reach with the mkcert cert).
private apiUrl(access: HostCalendarAccess): string {
return access.jmapSessionUrl.replace(/\/\.well-known\/jmap$/, '/jmap')
}
private async session(access: HostCalendarAccess): Promise<JmapSession> {
const res = await fetch(access.jmapSessionUrl, { headers: { Authorization: this.authHeader(access) } })
if (!res.ok) throw new Error(`JMAP session ${res.status} for ${access.email}`)
return (await res.json()) as JmapSession
}
private async call(access: HostCalendarAccess, using: string[], methodCalls: MethodCall[]): Promise<MethodResponse[]> {
const res = await fetch(this.apiUrl(access), {
method: 'POST',
headers: { Authorization: this.authHeader(access), 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
})
const text = await res.text()
if (!res.ok) throw new Error(`JMAP ${res.status}: ${text.slice(0, 200)}`)
const json = JSON.parse(text) as { methodResponses?: MethodResponse[] }
if (!json.methodResponses) throw new Error(`JMAP error: ${text.slice(0, 200)}`)
return json.methodResponses
}
private calAccountId(session: JmapSession): string {
const id = session.primaryAccounts?.[CALENDARS]
if (!id) throw new Error('Stalwart account has no calendar capability')
return id
}
async validateCredential(access: HostCalendarAccess): Promise<boolean> {
try {
await this.session(access)
return true
} catch (err) {
this.logger.warn(`Credential validation failed for ${access.email}: ${(err as Error).message}`)
return false
}
}
async listCalendars(access: HostCalendarAccess): Promise<CalendarRef[]> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
const list = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? []) as Array<{ id: string; name: string }>
return list.map((c) => ({ id: c.id, name: c.name }))
}
async getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(
access,
[CORE, PRINCIPALS, AVAILABILITY],
[
[
'Principal/getAvailability',
{ accountId, id: accountId, utcStart: fromUtc.toISOString(), utcEnd: toUtc.toISOString() },
'a',
],
],
)
const out = resp.find((r) => r[0] === 'Principal/getAvailability')
if (!out) {
const err = resp.find((r) => r[0] === 'error')?.[1]
throw new Error(`getAvailability failed: ${JSON.stringify(err)}`)
}
const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>
// Drop free/tentative-cancelled markers; count confirmed + tentative as busy.
return list
.filter((b) => b.busyStatus !== 'free')
.map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) }))
}
async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const calendarId = access.defaultCalendarId ?? (await this.firstCalendarId(access, accountId))
const startLocal = toJsCalLocal(event.startUtc, event.hostTimezone)
const durationIso = isoDuration(event.endUtc.getTime() - event.startUtc.getTime())
const descriptionParts = [
`Booked by: ${event.attendeeName} <${event.attendeeEmail}>`,
event.location ? `Location: ${event.location}` : '',
event.description ?? '',
].filter(Boolean)
const jsEvent: Record<string, unknown> = {
'@type': 'Event',
uid: event.uid,
calendarIds: { [calendarId]: true },
title: `${event.title}${event.attendeeName}`,
description: descriptionParts.join('\n'),
start: startLocal,
timeZone: event.hostTimezone,
duration: durationIso,
status: 'confirmed',
...(event.location ? { locations: { '1': { '@type': 'Location', name: event.location } } } : {}),
// Defensive: even though Community does not persist participants or send
// iMIP, mark the attendee scheduleAgent:client so a future server that DOES
// auto-send still won't double-send (dezky owns attendee notification).
replyTo: { imip: `mailto:${event.hostEmail}` },
participants: {
host: { '@type': 'Participant', email: event.hostEmail, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
attendee: {
'@type': 'Participant',
name: event.attendeeName,
email: event.attendeeEmail,
roles: { attendee: true },
participationStatus: 'accepted',
scheduleAgent: 'client',
},
},
}
const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, create: { e: jsEvent } }, 's']])
const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
const id = result?.created?.e?.id
if (!id) throw new Error(`CalendarEvent create failed: ${JSON.stringify(result?.notCreated ?? resp)}`)
return { uid: event.uid, id }
}
async deleteEvent(access: HostCalendarAccess, eventId: string): Promise<void> {
const session = await this.session(access)
const accountId = this.calAccountId(session)
const resp = await this.call(access, [CORE, CALENDARS], [['CalendarEvent/set', { accountId, destroy: [eventId] }, 'd']])
const result = resp.find((r) => r[0] === 'CalendarEvent/set')?.[1]
if ((result?.destroyed as string[] | undefined)?.includes(eventId)) return
const notDestroyed = result?.notDestroyed?.[eventId]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`CalendarEvent delete failed (id=${eventId}): ${JSON.stringify(notDestroyed)}`)
}
}
private async firstCalendarId(access: HostCalendarAccess, accountId: string): Promise<string> {
const resp = await this.call(access, [CORE, CALENDARS], [['Calendar/get', { accountId }, 'c']])
const id = (resp.find((r) => r[0] === 'Calendar/get')?.[1]?.list ?? [])[0]?.id
if (!id) throw new Error('Host has no writable calendar')
return id
}
}
// Render a UTC instant as a JSCalendar floating local date-time string in `tz`
// ("YYYY-MM-DDTHH:mm:ss", no offset — the offset is carried by `timeZone`).
function toJsCalLocal(utc: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).formatToParts(utc)
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '00'
// en-CA gives hour "24" at midnight in some runtimes; normalise to "00".
const hour = get('hour') === '24' ? '00' : get('hour')
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`
}
// Milliseconds → ISO-8601 duration (whole minutes are enough for bookings).
function isoDuration(ms: number): string {
const totalMinutes = Math.round(ms / 60000)
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
return `PT${h ? `${h}H` : ''}${m || !h ? `${m}M` : ''}`
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { IntegrationsModule } from '../../integrations/integrations.module.js'
import { Host, HostSchema } from '../../schemas/scheduling-host.schema.js'
import { StalwartCredential, StalwartCredentialSchema } from '../../schemas/stalwart-credential.schema.js'
import { CredentialCipher } from '../crypto/credential-cipher.js'
import { CredentialProvisioner } from './credential-provisioner.service.js'
import { JmapCalendarGateway } from './jmap-calendar.gateway.js'
// Isolated Stalwart calendar integration — the only place that knows JMAP. Kept
// self-contained so it can be extracted to a microservice later (CLAUDE.md).
@Module({
imports: [
MongooseModule.forFeature([
{ name: StalwartCredential.name, schema: StalwartCredentialSchema },
{ name: Host.name, schema: HostSchema },
]),
IntegrationsModule, // StalwartClient (admin JMAP) for app-password provisioning
],
providers: [CredentialCipher, CredentialProvisioner, JmapCalendarGateway],
exports: [CredentialProvisioner, JmapCalendarGateway],
})
export class StalwartCalendarModule {}
@@ -0,0 +1,64 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type AvailabilityScheduleDocument = HydratedDocument<AvailabilitySchedule>
// An interval within a day, in MINUTES from local midnight in the schedule's tz.
// e.g. 09:0017:00 => { startMinute: 540, endMinute: 1020 }.
export interface MinuteInterval {
startMinute: number
endMinute: number
}
export interface WeeklyRule {
dayOfWeek: number // 0=Sun .. 6=Sat
intervals: MinuteInterval[]
}
export interface DateOverride {
date: string // "YYYY-MM-DD" in the schedule tz
isUnavailable: boolean // true => whole day blocked (intervals ignored)
intervals: MinuteInterval[] // replace the weekly intervals for this date
}
// Recurring weekly availability + specific-date overrides, authored in `timezone`.
// Slot computation converts these local windows to UTC via Luxon (DST-correct).
@Schema({ collection: 'scheduling_availability_schedules', timestamps: true })
export class AvailabilitySchedule {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
hostId!: Types.ObjectId
@Prop({ required: true, trim: true })
name!: string
@Prop({ required: true })
timezone!: string
@Prop({
type: [
{
dayOfWeek: { type: Number, min: 0, max: 6, required: true },
intervals: [{ startMinute: Number, endMinute: Number }],
},
],
default: [],
})
weeklyRules!: WeeklyRule[]
@Prop({
type: [
{
date: { type: String, required: true },
isUnavailable: { type: Boolean, default: false },
intervals: [{ startMinute: Number, endMinute: Number }],
},
],
default: [],
})
dateOverrides!: DateOverride[]
}
export const AvailabilityScheduleSchema = SchemaFactory.createForClass(AvailabilitySchedule)
@@ -0,0 +1,81 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
import type { LocationType } from './event-type.schema.js'
export type BookingDocument = HydratedDocument<Booking>
// 'pending' is the compensating state when the SlotLock is held but the calendar
// write hasn't succeeded yet (§8.2.4) — never surfaced as a confirmed booking.
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'rescheduled'
// A confirmed appointment. All instants are UTC; attendee/host tz are IANA
// strings for display. `calendarEventUid` is generated client-side BEFORE the
// Stalwart write so a retried write is idempotent (§9). `manageToken` backs the
// unauthenticated self-service cancel/reschedule links.
@Schema({ collection: 'scheduling_bookings', timestamps: true })
export class Booking {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'EventType', required: true, index: true })
eventTypeId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
hostId!: Types.ObjectId
@Prop({ enum: ['pending', 'confirmed', 'cancelled', 'rescheduled'], default: 'pending', index: true })
status!: BookingStatus
@Prop({ required: true, index: true })
startUtc!: Date
@Prop({ required: true })
endUtc!: Date
@Prop({ required: true, trim: true })
attendeeName!: string
@Prop({ required: true, lowercase: true, trim: true })
attendeeEmail!: string
@Prop({ required: true })
attendeeTimezone!: string
@Prop({ trim: true })
attendeeNotes?: string
// UID written to Stalwart — generated client-side for idempotent retries.
@Prop({ required: true, index: true })
calendarEventUid!: string
// Server-assigned CalendarEvent id (Stalwart's own id, distinct from the UID).
// Needed for delete/update, which address events by server id, not UID.
@Prop()
calendarEventId?: string
@Prop({ enum: ['jitsi', 'phone', 'in_person', 'custom'] })
locationType?: LocationType
// Resolved location (e.g. generated Jitsi room url, or copied details).
@Prop({ trim: true })
locationUrl?: string
// Opaque token for the self-service manage page. Unique + indexed for lookup.
@Prop({ required: true, unique: true, index: true })
manageToken!: string
@Prop({ type: Types.ObjectId, ref: 'Booking' })
rescheduledFromBookingId?: Types.ObjectId
@Prop()
cancelledAt?: Date
@Prop({ trim: true })
cancellationReason?: string
// Reminder bookkeeping for Phase 2 (e.g. 'none' | 'sent_24h').
@Prop({ default: 'none' })
reminderState!: string
}
export const BookingSchema = SchemaFactory.createForClass(Booking)
@@ -0,0 +1,67 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type EventTypeDocument = HydratedDocument<EventType>
export type LocationType = 'jitsi' | 'phone' | 'in_person' | 'custom'
// A bookable appointment type for a host (e.g. "30-min consultation"). Carries
// the booking rules slot computation needs: duration, granularity, buffers,
// notice window, and horizon.
@Schema({ collection: 'scheduling_event_types', timestamps: true })
export class EventType {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, index: true })
hostId!: Types.ObjectId
@Prop({ required: true, lowercase: true, trim: true })
slug!: string
@Prop({ required: true, trim: true })
title!: string
@Prop({ trim: true })
description?: string
@Prop({ required: true, min: 1 })
durationMinutes!: number
// Granularity of offered start times (e.g. 15 => :00/:15/:30/:45).
@Prop({ required: true, min: 1, default: 15 })
slotIntervalMinutes!: number
@Prop({ min: 0, default: 0 })
bufferBeforeMinutes!: number
@Prop({ min: 0, default: 0 })
bufferAfterMinutes!: number
// Earliest bookable time relative to now.
@Prop({ min: 0, default: 0 })
minimumNoticeMinutes!: number
// Booking horizon — how far ahead slots are offered.
@Prop({ min: 1, default: 60 })
maximumDaysInFuture!: number
@Prop({ type: Types.ObjectId, ref: 'AvailabilitySchedule', required: true })
availabilityScheduleId!: Types.ObjectId
@Prop({ enum: ['jitsi', 'phone', 'in_person', 'custom'], default: 'jitsi' })
locationType!: LocationType
// Free text for phone / in_person / custom (e.g. a phone number or address).
@Prop({ trim: true })
locationDetails?: string
@Prop({ trim: true })
color?: string
@Prop({ default: true, index: true })
isActive!: boolean
}
export const EventTypeSchema = SchemaFactory.createForClass(EventType)
EventTypeSchema.index({ tenantId: 1, hostId: 1, slug: 1 }, { unique: true })
@@ -0,0 +1,55 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type HostDocument = HydratedDocument<Host>
// A bookable user. One Host per workspace user who exposes public booking pages.
// A User becomes a Host only when made bookable (so most users never get a row).
// `email` matches the Stalwart account; `stalwartAccountId` is the management
// account id (x:Account) used to provision the calendar credential and resolve
// the calendar principal. Kept as a distinct collection from User so scheduling
// stays extractable to a microservice later (see CLAUDE.md §"Hosting").
@Schema({ collection: 'scheduling_hosts', timestamps: true })
export class Host {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
// OIDC subject (Authentik `sub`) of the underlying workspace user.
@Prop({ required: true, index: true })
authentikUserId!: string
@Prop({ required: true, lowercase: true, trim: true })
email!: string
@Prop({ required: true, trim: true })
displayName!: string
// URL segment in the public booking page: booking.dezky.eu/:tenantSlug/:slug/...
@Prop({ required: true, lowercase: true, trim: true })
slug!: string
// IANA tz the host's availability is authored/displayed in, e.g. "Europe/Copenhagen".
@Prop({ required: true })
timezone!: string
// Stalwart management account id (x:Account) — used for on-behalf credential
// provisioning and to locate the calendar principal.
@Prop({ required: true })
stalwartAccountId!: string
// Calendar the confirmed bookings are written to.
@Prop()
defaultCalendarId?: string
// Calendars consulted for free/busy. Defaults to [defaultCalendarId] at read time.
@Prop({ type: [String], default: [] })
busyCalendarIds!: string[]
@Prop({ default: true, index: true })
isActive!: boolean
}
export const HostSchema = SchemaFactory.createForClass(Host)
// Public URL uniqueness + fast lookup; one Host row per workspace user.
HostSchema.index({ tenantId: 1, slug: 1 }, { unique: true })
HostSchema.index({ tenantId: 1, authentikUserId: 1 }, { unique: true })
@@ -0,0 +1,49 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type SlotLockDocument = HydratedDocument<SlotLock>
// Atomic double-booking guard (§8.2 layer 1). The unique (hostId, startUtc) index
// is the hard guarantee: two concurrent confirmations for the same start can't
// both insert — the loser gets a duplicate-key error and a clean "slot taken".
//
// Lifecycle:
// - hold during checkout: expiresAt = now + N min (TTL reaps abandoned holds).
// - confirmed: bookingId set, expiresAt = null. MongoDB's TTL index only deletes
// docs whose indexed field is a Date, so a null expiresAt makes the lock
// permanent — keeping the slot blocked for the life of the booking.
@Schema({ collection: 'scheduling_slot_locks', timestamps: true })
export class SlotLock {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Host', required: true })
hostId!: Types.ObjectId
@Prop({ required: true })
startUtc!: Date
@Prop({ required: true })
endUtc!: Date
// TTL anchor for un-confirmed holds; null once confirmed (permanent).
@Prop({ type: Date, default: null })
expiresAt!: Date | null
// Opaque token returned by POST /holds. A confirm may only claim an existing
// hold by presenting its token — so a confirm without the token falls through
// to a fresh unique insert (dup-key => "slot taken") and can never hijack
// someone else's active hold.
@Prop({ type: String, default: null })
holdToken!: string | null
@Prop({ type: Types.ObjectId, ref: 'Booking', default: null })
bookingId!: Types.ObjectId | null
}
export const SlotLockSchema = SchemaFactory.createForClass(SlotLock)
// Hard uniqueness: one lock per (host, start).
SlotLockSchema.index({ hostId: 1, startUtc: 1 }, { unique: true })
// TTL: reap abandoned holds. expireAfterSeconds:0 => delete when expiresAt passes;
// docs with expiresAt=null (confirmed) are never reaped.
SlotLockSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
@@ -0,0 +1,49 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type StalwartCredentialDocument = HydratedDocument<StalwartCredential>
export type StalwartCredentialType = 'app_password' | 'oauth'
// Per-host Stalwart credential, encrypted at rest (AES-256-GCM via
// CredentialCipher). One row per host. The secret is an app password minted
// on-behalf through the Stalwart admin JMAP (x:AppPassword/set {accountId}); see
// reference: Phase 0 spike. The plaintext secret is NEVER stored or logged —
// only the ciphertext + iv + authTag triplet.
@Schema({ collection: 'scheduling_stalwart_credentials', timestamps: true })
export class StalwartCredential {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Host', required: true, unique: true, index: true })
hostId!: Types.ObjectId
@Prop({ enum: ['app_password', 'oauth'], default: 'app_password' })
type!: StalwartCredentialType
@Prop({ required: true })
encryptedSecret!: string
@Prop({ required: true })
iv!: string
@Prop({ required: true })
authTag!: string
// Stalwart AppPassword object id — kept so we can rotate/destroy it later.
@Prop()
appPasswordId?: string
// JMAP session endpoint (.well-known/jmap) the secret authenticates against.
@Prop({ required: true })
jmapSessionUrl!: string
// CalDAV base URL — retained for the (currently shelved) fallback transport.
@Prop()
caldavBaseUrl?: string
@Prop()
lastValidatedAt?: Date
}
export const StalwartCredentialSchema = SchemaFactory.createForClass(StalwartCredential)