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
@@ -269,6 +269,50 @@ export class StalwartClient {
}
}
// The Stalwart account id whose primary address matches `email`, or undefined.
// Stalwart's account query has no email filter, so we list + match in memory.
async findAccountIdByEmail(email: string): Promise<string | undefined> {
const target = email.trim().toLowerCase()
const accounts = await this.listAccountsWithAliases()
return accounts.find((a) => a.emailAddress?.toLowerCase() === target)?.id
}
// ── App passwords (per-account credentials for on-behalf calendar access) ────
// Used by dezky Scheduling to obtain a scoped credential for a host without a
// user-facing "connect calendar" step. Admin mints on-behalf by passing the
// target account id; the returned `secret` is shown once and must be stored
// encrypted by the caller (it is never logged here).
async createAppPassword(accountId: string, description: string): Promise<{ id: string; secret: string }> {
const resp = await this.jmap([
[
'x:AppPassword/set',
{
accountId,
create: { ap: { description, permissions: { '@type': 'Inherit' }, allowedIps: {} } },
},
'0',
],
])
const created = resp[0][1].created?.ap
if (!created?.id || !created?.secret) {
throw new Error(
`Stalwart app-password create failed for account ${accountId}: ${JSON.stringify(resp[0][1].notCreated)}`,
)
}
return { id: created.id, secret: created.secret }
}
async deleteAppPassword(accountId: string, id: string): Promise<void> {
const resp = await this.jmap([['x:AppPassword/set', { accountId, destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) return
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart app-password delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
}
}
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
// Every mailbox + its aliases. Stalwart's account query has no domain filter,