From 5ed3d2bc5f102c761301362d660db04d2588a29d Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 7 Jun 2026 00:17:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(scheduling):=20dezky=20Scheduling=20?= =?UTF-8?q?=E2=80=94=20Calendly-style=20booking=20on=20Stalwart=20calendar?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/booking/.dockerignore | 6 + apps/booking/.gitignore | 7 + apps/booking/Dockerfile | 22 + apps/booking/app.vue | 3 + apps/booking/assets/styles/base.css | 48 + apps/booking/nuxt.config.ts | 41 + apps/booking/package.json | 27 + .../[hostSlug]/[eventTypeSlug].vue | 285 + apps/booking/pages/index.vue | 23 + apps/booking/pages/manage/[token].vue | 118 + apps/booking/pnpm-lock.yaml | 7087 +++++++++++++++++ apps/booking/public/favicon.svg | 1 + .../[eventTypeSlug]/bookings.post.ts | 7 + .../[hostSlug]/[eventTypeSlug]/holds.post.ts | 7 + .../[hostSlug]/[eventTypeSlug]/index.get.ts | 6 + .../[hostSlug]/[eventTypeSlug]/slots.get.ts | 7 + .../public/bookings/[token]/cancel.post.ts | 5 + .../api/public/bookings/[token]/index.get.ts | 4 + .../bookings/[token]/reschedule.post.ts | 5 + apps/booking/server/utils/forward.ts | 23 + apps/booking/tsconfig.json | 3 + apps/booking/types/booking.ts | 42 + apps/booking/utils/time.ts | 67 + apps/portal/components/PortalSidebar.vue | 2 + apps/portal/pages/admin/scheduling.vue | 715 ++ .../[hostSlug]/[eventTypeSlug].get.ts | 18 + .../tenants/[slug]/scheduling/[...path].ts | 34 + .../docker-compose/docker-compose.yml | 39 + services/platform-api/jest.config.mjs | 32 + services/platform-api/package.json | 9 +- services/platform-api/pnpm-lock.yaml | 2129 +++++ services/platform-api/src/app.module.ts | 2 + .../src/integrations/stalwart.client.ts | 44 + .../availability/availability.service.ts | 68 + .../availability/dto/availability-dtos.ts | 86 + .../scheduling/bookings/bookings.service.ts | 342 + .../scheduling/crypto/credential-cipher.ts | 55 + .../src/scheduling/email/booking-templates.ts | 98 + .../platform-api/src/scheduling/email/ics.ts | 36 + .../scheduling/email/jmap-mailer.service.ts | 135 + .../event-types/dto/event-type-dtos.ts | 99 + .../event-types/event-types.service.ts | 72 + .../scheduling/hosts/dto/create-host.dto.ts | 25 + .../src/scheduling/hosts/hosts.service.ts | 119 + .../src/scheduling/public/dto/public-dtos.ts | 63 + .../public/public-scheduling.controller.ts | 156 + .../public/public-scheduling.service.ts | 52 + .../scheduling/scheduling-admin.controller.ts | 207 + .../src/scheduling/scheduling.module.ts | 57 + .../scheduling/slots/slot-computer.spec.ts | 146 + .../src/scheduling/slots/slot-computer.ts | 119 + .../src/scheduling/slots/slot.service.ts | 87 + .../calendar-gateway.types.ts | 48 + .../credential-provisioner.service.ts | 102 + .../jmap-calendar.gateway.ts | 206 + .../stalwart-calendar.module.ts | 23 + .../schemas/availability-schedule.schema.ts | 64 + .../src/schemas/booking.schema.ts | 81 + .../src/schemas/event-type.schema.ts | 67 + .../src/schemas/scheduling-host.schema.ts | 55 + .../src/schemas/slot-lock.schema.ts | 49 + .../src/schemas/stalwart-credential.schema.ts | 49 + 62 files changed, 13633 insertions(+), 1 deletion(-) create mode 100644 apps/booking/.dockerignore create mode 100644 apps/booking/.gitignore create mode 100644 apps/booking/Dockerfile create mode 100644 apps/booking/app.vue create mode 100644 apps/booking/assets/styles/base.css create mode 100644 apps/booking/nuxt.config.ts create mode 100644 apps/booking/package.json create mode 100644 apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue create mode 100644 apps/booking/pages/index.vue create mode 100644 apps/booking/pages/manage/[token].vue create mode 100644 apps/booking/pnpm-lock.yaml create mode 100644 apps/booking/public/favicon.svg create mode 100644 apps/booking/server/api/public/[tenantSlug]/[hostSlug]/[eventTypeSlug]/bookings.post.ts create mode 100644 apps/booking/server/api/public/[tenantSlug]/[hostSlug]/[eventTypeSlug]/holds.post.ts create mode 100644 apps/booking/server/api/public/[tenantSlug]/[hostSlug]/[eventTypeSlug]/index.get.ts create mode 100644 apps/booking/server/api/public/[tenantSlug]/[hostSlug]/[eventTypeSlug]/slots.get.ts create mode 100644 apps/booking/server/api/public/bookings/[token]/cancel.post.ts create mode 100644 apps/booking/server/api/public/bookings/[token]/index.get.ts create mode 100644 apps/booking/server/api/public/bookings/[token]/reschedule.post.ts create mode 100644 apps/booking/server/utils/forward.ts create mode 100644 apps/booking/tsconfig.json create mode 100644 apps/booking/types/booking.ts create mode 100644 apps/booking/utils/time.ts create mode 100644 apps/portal/pages/admin/scheduling.vue create mode 100644 apps/portal/server/api/scheduling-slots/[tenantSlug]/[hostSlug]/[eventTypeSlug].get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/scheduling/[...path].ts create mode 100644 services/platform-api/jest.config.mjs create mode 100644 services/platform-api/src/scheduling/availability/availability.service.ts create mode 100644 services/platform-api/src/scheduling/availability/dto/availability-dtos.ts create mode 100644 services/platform-api/src/scheduling/bookings/bookings.service.ts create mode 100644 services/platform-api/src/scheduling/crypto/credential-cipher.ts create mode 100644 services/platform-api/src/scheduling/email/booking-templates.ts create mode 100644 services/platform-api/src/scheduling/email/ics.ts create mode 100644 services/platform-api/src/scheduling/email/jmap-mailer.service.ts create mode 100644 services/platform-api/src/scheduling/event-types/dto/event-type-dtos.ts create mode 100644 services/platform-api/src/scheduling/event-types/event-types.service.ts create mode 100644 services/platform-api/src/scheduling/hosts/dto/create-host.dto.ts create mode 100644 services/platform-api/src/scheduling/hosts/hosts.service.ts create mode 100644 services/platform-api/src/scheduling/public/dto/public-dtos.ts create mode 100644 services/platform-api/src/scheduling/public/public-scheduling.controller.ts create mode 100644 services/platform-api/src/scheduling/public/public-scheduling.service.ts create mode 100644 services/platform-api/src/scheduling/scheduling-admin.controller.ts create mode 100644 services/platform-api/src/scheduling/scheduling.module.ts create mode 100644 services/platform-api/src/scheduling/slots/slot-computer.spec.ts create mode 100644 services/platform-api/src/scheduling/slots/slot-computer.ts create mode 100644 services/platform-api/src/scheduling/slots/slot.service.ts create mode 100644 services/platform-api/src/scheduling/stalwart-calendar/calendar-gateway.types.ts create mode 100644 services/platform-api/src/scheduling/stalwart-calendar/credential-provisioner.service.ts create mode 100644 services/platform-api/src/scheduling/stalwart-calendar/jmap-calendar.gateway.ts create mode 100644 services/platform-api/src/scheduling/stalwart-calendar/stalwart-calendar.module.ts create mode 100644 services/platform-api/src/schemas/availability-schedule.schema.ts create mode 100644 services/platform-api/src/schemas/booking.schema.ts create mode 100644 services/platform-api/src/schemas/event-type.schema.ts create mode 100644 services/platform-api/src/schemas/scheduling-host.schema.ts create mode 100644 services/platform-api/src/schemas/slot-lock.schema.ts create mode 100644 services/platform-api/src/schemas/stalwart-credential.schema.ts diff --git a/apps/booking/.dockerignore b/apps/booking/.dockerignore new file mode 100644 index 0000000..7730896 --- /dev/null +++ b/apps/booking/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.nuxt +.output +.git +dist +*.log diff --git a/apps/booking/.gitignore b/apps/booking/.gitignore new file mode 100644 index 0000000..aea644d --- /dev/null +++ b/apps/booking/.gitignore @@ -0,0 +1,7 @@ +# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles. +package-lock.json +node_modules +.nuxt +.output +.data +*.log diff --git a/apps/booking/Dockerfile b/apps/booking/Dockerfile new file mode 100644 index 0000000..ac3979e --- /dev/null +++ b/apps/booking/Dockerfile @@ -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"] diff --git a/apps/booking/app.vue b/apps/booking/app.vue new file mode 100644 index 0000000..8f62b8b --- /dev/null +++ b/apps/booking/app.vue @@ -0,0 +1,3 @@ + diff --git a/apps/booking/assets/styles/base.css b/apps/booking/assets/styles/base.css new file mode 100644 index 0000000..5fb795a --- /dev/null +++ b/apps/booking/assets/styles/base.css @@ -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; } diff --git a/apps/booking/nuxt.config.ts b/apps/booking/nuxt.config.ts new file mode 100644 index 0000000..1519add --- /dev/null +++ b/apps/booking/nuxt.config.ts @@ -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, + }, + }, +}) diff --git a/apps/booking/package.json b/apps/booking/package.json new file mode 100644 index 0000000..1c559c5 --- /dev/null +++ b/apps/booking/package.json @@ -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" +} diff --git a/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue b/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue new file mode 100644 index 0000000..aafd9c5 --- /dev/null +++ b/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue @@ -0,0 +1,285 @@ + + +