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:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles.
|
||||||
|
package-lock.json
|
||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
*.log
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
@@ -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; }
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
|
Couldn’t verify availability. <button class="link" @click="loadSlots">Retry</button>
|
||||||
|
</p>
|
||||||
|
<p v-else-if="slotsState === 'error'" class="err">Couldn’t 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>You’re 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Generated
+7087
File diff suppressed because it is too large
Load Diff
@@ -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 |
+7
@@ -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 })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -62,6 +62,7 @@ const ADMIN_NAV: NavRow[] = [
|
|||||||
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
|
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
|
||||||
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
||||||
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
|
{ 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: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
|
||||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
||||||
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
|
{ 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/users')) return 'users'
|
||||||
if (p.startsWith('/admin/mail')) return 'mail'
|
if (p.startsWith('/admin/mail')) return 'mail'
|
||||||
if (p.startsWith('/admin/meetings')) return 'meetings'
|
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/chat')) return 'chat'
|
||||||
if (p.startsWith('/admin/domains')) return 'domains'
|
if (p.startsWith('/admin/domains')) return 'domains'
|
||||||
if (p.startsWith('/admin/storage')) return 'storage'
|
if (p.startsWith('/admin/storage')) return 'storage'
|
||||||
|
|||||||
@@ -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? They’ll 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 & 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 (2–40 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 can’t 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 (2–40 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">Couldn’t 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>
|
||||||
+18
@@ -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:
|
platform_api_node_modules:
|
||||||
operator_node_modules:
|
operator_node_modules:
|
||||||
website_node_modules:
|
website_node_modules:
|
||||||
|
booking_node_modules:
|
||||||
# MinIO data (S3-compatible cold storage for audit archives). Production
|
# MinIO data (S3-compatible cold storage for audit archives). Production
|
||||||
# swaps the endpoint to Hetzner Object Storage and this volume goes away.
|
# swaps the endpoint to Hetzner Object Storage and this volume goes away.
|
||||||
minio_data:
|
minio_data:
|
||||||
@@ -570,6 +571,38 @@ services:
|
|||||||
- traefik.http.routers.website.tls=true
|
- traefik.http.routers.website.tls=true
|
||||||
- traefik.http.services.website.loadbalancer.server.port=3000
|
- 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,
|
# platform-api — NestJS service. Owns tenants, partners, users,
|
||||||
# subscriptions, and provisioning orchestration.
|
# subscriptions, and provisioning orchestration.
|
||||||
@@ -647,6 +680,12 @@ services:
|
|||||||
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_ENABLED:-false}
|
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:
|
volumes:
|
||||||
- ../../services/platform-api:/app
|
- ../../services/platform-api:/app
|
||||||
- platform_api_node_modules:/app/node_modules
|
- platform_api_node_modules:/app/node_modules
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"test": "jest",
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,9 +19,12 @@
|
|||||||
"@nestjs/platform-fastify": "^10.4.0",
|
"@nestjs/platform-fastify": "^10.4.0",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/mongoose": "^10.1.0",
|
"@nestjs/mongoose": "^10.1.0",
|
||||||
|
"@nestjs/throttler": "^6.2.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"ical-generator": "^8.0.1",
|
||||||
"jose": "^5.9.0",
|
"jose": "^5.9.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"mongoose": "^8.7.0",
|
"mongoose": "^8.7.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
@@ -30,7 +33,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
"@nestjs/testing": "^10.4.0",
|
"@nestjs/testing": "^10.4.0",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2129
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { MailModule } from './mail/mail.module.js'
|
|||||||
import { MeModule } from './me/me.module.js'
|
import { MeModule } from './me/me.module.js'
|
||||||
import { PartnersModule } from './partners/partners.module.js'
|
import { PartnersModule } from './partners/partners.module.js'
|
||||||
import { PricesModule } from './prices/prices.module.js'
|
import { PricesModule } from './prices/prices.module.js'
|
||||||
|
import { SchedulingModule } from './scheduling/scheduling.module.js'
|
||||||
import { SeedModule } from './seed/seed.module.js'
|
import { SeedModule } from './seed/seed.module.js'
|
||||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
|
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
|
||||||
import { TenantsModule } from './tenants/tenants.module.js'
|
import { TenantsModule } from './tenants/tenants.module.js'
|
||||||
@@ -37,6 +38,7 @@ import { UsersModule } from './users/users.module.js'
|
|||||||
FlagsModule,
|
FlagsModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
IngestModule,
|
IngestModule,
|
||||||
|
SchedulingModule,
|
||||||
SeedModule,
|
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) ──────────────────────
|
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
|
||||||
|
|
||||||
// Every mailbox + its aliases. Stalwart's account query has no domain filter,
|
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:00–17: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:00–17: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:00–11: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:00–10:30 local (08:00–08: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:00–10: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:00–03: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), // 09–17 normally
|
||||||
|
dateOverrides: [{ date: '2026-06-01', isUnavailable: false, intervals: [{ startMinute: 780, endMinute: 840 }] }], // 13:00–14: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>
|
||||||
|
}
|
||||||
+102
@@ -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:00–17: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)
|
||||||
Reference in New Issue
Block a user