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