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:
@@ -0,0 +1,63 @@
|
||||
import { IsEmail, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'
|
||||
|
||||
// IANA timezone sanity check (Area/Location, optionally a third segment). Not a
|
||||
// full tz-database membership test — Luxon rejects unknown zones downstream.
|
||||
const TZ = /^[A-Za-z]+\/[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)?$/
|
||||
|
||||
export class SlotsQueryDto {
|
||||
@IsISO8601()
|
||||
from!: string
|
||||
|
||||
@IsISO8601()
|
||||
to!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(TZ, { message: 'timezone must be an IANA zone like Europe/Copenhagen' })
|
||||
timezone!: string
|
||||
}
|
||||
|
||||
export class CreateHoldDto {
|
||||
@IsISO8601()
|
||||
startUtc!: string
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@IsISO8601()
|
||||
startUtc!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
attendeeName!: string
|
||||
|
||||
@IsEmail()
|
||||
@MaxLength(254)
|
||||
attendeeEmail!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(TZ, { message: 'attendeeTimezone must be an IANA zone like Europe/Copenhagen' })
|
||||
attendeeTimezone!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
attendeeNotes?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
holdId?: string
|
||||
}
|
||||
|
||||
export class CancelBookingDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export class RescheduleBookingDto {
|
||||
@IsISO8601()
|
||||
startUtc!: string
|
||||
}
|
||||
Reference in New Issue
Block a user