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:
+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)
|
||||
Reference in New Issue
Block a user