feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars

First-party booking system on top of Stalwart calendars (no third-party
scheduling dependency). Hosts expose public booking pages; visitors pick a
slot computed from the host's live Stalwart free/busy, and confirming writes
the event to the host's calendar and sends a dezky-branded confirmation with
an .ics.

platform-api (services/platform-api/src/scheduling):
- Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule,
  EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL).
- StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability,
  event create/delete, scheduleAgent=client) + on-behalf app-password
  provisioning. CredentialCipher for at-rest encryption.
- DST-correct slot engine (Luxon) with unit tests; two-layer double-booking
  guard (atomic SlotLock + live free/busy re-check).
- Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission,
  self-service manage tokens. /api/v1 public + tenant-gated admin routes,
  per-IP rate limiting.

apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) —
path-based tenant resolution, per-tenant brand colour, booking + manage flows.

apps/portal: admin scheduling page (hosts, event types, availability, bookings
with edit/delete + admin cancel/reschedule) and proxy routes.

infra: booking dev service in docker-compose; scheduling env vars.
This commit is contained in:
Ronni Baslund
2026-06-07 00:17:36 +02:00
parent aee8f13899
commit 5ed3d2bc5f
62 changed files with 13633 additions and 1 deletions
@@ -0,0 +1,98 @@
// Branded, dependency-free booking email templates (text + HTML). Per CLAUDE.md
// the brand surface is whitelabel: `brandName`/`brandColor` come from the tenant,
// not fixed dezky styling. End-user copy may be localized later; English for now.
export interface BookingEmailContext {
brandName: string
brandColor?: string
eventTitle: string
hostName: string
attendeeName: string
startUtc: Date
endUtc: Date
attendeeTimezone: string
location?: string
manageUrl: string
}
export interface RenderedEmail {
subject: string
text: string
html: string
}
function fmtRange(start: Date, end: Date, tz: string): string {
const date = new Intl.DateTimeFormat('en-GB', {
timeZone: tz, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).format(start)
const t = (d: Date) =>
new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(d)
return `${date}, ${t(start)}${t(end)} (${tz})`
}
function shell(accent: string, brandName: string, heading: string, bodyHtml: string): string {
return `<!doctype html><html><body style="margin:0;background:#f6f6f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a">
<div style="max-width:520px;margin:0 auto;padding:32px 16px">
<div style="background:#fff;border:1px solid #ececec;border-radius:14px;overflow:hidden">
<div style="height:6px;background:${accent}"></div>
<div style="padding:28px">
<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#888">${escapeHtml(brandName)}</div>
<h1 style="font-size:20px;margin:8px 0 16px">${escapeHtml(heading)}</h1>
${bodyHtml}
</div>
</div>
<p style="text-align:center;color:#aaa;font-size:12px;margin-top:16px">Powered by ${escapeHtml(brandName)}</p>
</div></body></html>`
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
}
export function confirmationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Confirmed: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking is confirmed.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
ctx.location ? `Location: ${ctx.location}` : '',
``,
`A calendar invite is attached.`,
`Need to change it? ${ctx.manageUrl}`,
].filter(Boolean).join('\n')
const html = shell(accent, ctx.brandName, 'Your booking is confirmed', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444">${escapeHtml(when)}</p>
${ctx.location ? `<p style="margin:0 0 4px;color:#444">${escapeHtml(ctx.location)}</p>` : ''}
<p style="margin:18px 0 0;font-size:14px;color:#666">A calendar invite (.ics) is attached.</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Reschedule or cancel</a></p>
`)
return { subject, text, html }
}
export function cancellationEmail(ctx: BookingEmailContext): RenderedEmail {
const accent = ctx.brandColor || '#1a1a1a'
const when = fmtRange(ctx.startUtc, ctx.endUtc, ctx.attendeeTimezone)
const subject = `Cancelled: ${ctx.eventTitle} with ${ctx.hostName}`
const text = [
`Hi ${ctx.attendeeName},`,
``,
`Your booking has been cancelled.`,
``,
`${ctx.eventTitle} with ${ctx.hostName}`,
when,
``,
`You can book a new time here: ${ctx.manageUrl}`,
].join('\n')
const html = shell(accent, ctx.brandName, 'Your booking was cancelled', `
<p style="margin:0 0 6px"><strong>${escapeHtml(ctx.eventTitle)}</strong> with ${escapeHtml(ctx.hostName)}</p>
<p style="margin:0 0 4px;color:#444;text-decoration:line-through">${escapeHtml(when)}</p>
<p style="margin:18px 0 0"><a href="${ctx.manageUrl}" style="display:inline-block;background:${accent};color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-size:14px">Book a new time</a></p>
`)
return { subject, text, html }
}