feat(scheduling): tenant webhooks for booking lifecycle
This commit is contained in:
@@ -441,6 +441,103 @@ const detailTabs = computed(() => [
|
|||||||
{ value: 'availability', label: 'Availability', count: availability.value.length },
|
{ value: 'availability', label: 'Availability', count: availability.value.length },
|
||||||
{ value: 'bookings', label: 'Bookings', count: bookings.value.length },
|
{ value: 'bookings', label: 'Bookings', count: bookings.value.length },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// ── Webhooks (tenant-level: signed POSTs on booking lifecycle) ──
|
||||||
|
interface Webhook { _id: string; url: string; secret: string; events: string[]; active: boolean }
|
||||||
|
const ALL_WEBHOOK_EVENTS = ['booking.created', 'booking.cancelled', 'booking.rescheduled']
|
||||||
|
const webhooks = ref<Webhook[]>([])
|
||||||
|
const webhooksLoaded = ref(false)
|
||||||
|
const revealedSecrets = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
async function loadWebhooks() {
|
||||||
|
try {
|
||||||
|
webhooks.value = (await request(`${base.value}/webhooks`)) as Webhook[]
|
||||||
|
} catch (err) {
|
||||||
|
toastErr(err, 'Could not load webhooks')
|
||||||
|
} finally {
|
||||||
|
webhooksLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slug.value) loadWebhooks()
|
||||||
|
watch(slug, (s) => { if (s) loadWebhooks() })
|
||||||
|
|
||||||
|
const whOpen = ref(false)
|
||||||
|
const whBusy = ref(false)
|
||||||
|
const whEditingId = ref<string | null>(null)
|
||||||
|
const whForm = reactive({ url: '', events: [...ALL_WEBHOOK_EVENTS], active: true })
|
||||||
|
const whUrlValid = computed(() => /^https?:\/\/.+/i.test(whForm.url.trim()))
|
||||||
|
|
||||||
|
function openWebhook(w?: Webhook) {
|
||||||
|
if (w) {
|
||||||
|
whEditingId.value = w._id
|
||||||
|
Object.assign(whForm, { url: w.url, events: [...w.events], active: w.active })
|
||||||
|
} else {
|
||||||
|
whEditingId.value = null
|
||||||
|
Object.assign(whForm, { url: '', events: [...ALL_WEBHOOK_EVENTS], active: true })
|
||||||
|
}
|
||||||
|
whOpen.value = true
|
||||||
|
}
|
||||||
|
function toggleWhEvent(ev: string) {
|
||||||
|
const i = whForm.events.indexOf(ev)
|
||||||
|
if (i === -1) whForm.events.push(ev)
|
||||||
|
else whForm.events.splice(i, 1)
|
||||||
|
}
|
||||||
|
async function submitWebhook() {
|
||||||
|
if (!whUrlValid.value || !whForm.events.length) return
|
||||||
|
whBusy.value = true
|
||||||
|
try {
|
||||||
|
if (whEditingId.value) {
|
||||||
|
await request(`${base.value}/webhooks/${whEditingId.value}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { url: whForm.url.trim(), events: whForm.events, active: whForm.active },
|
||||||
|
})
|
||||||
|
toast.ok('Webhook updated')
|
||||||
|
} else {
|
||||||
|
await request(`${base.value}/webhooks`, { method: 'POST', body: { url: whForm.url.trim(), events: whForm.events } })
|
||||||
|
toast.ok('Webhook created', 'Copy the signing secret now — keep it safe.')
|
||||||
|
}
|
||||||
|
whOpen.value = false
|
||||||
|
await loadWebhooks()
|
||||||
|
} catch (err) {
|
||||||
|
toastErr(err, 'Could not save webhook')
|
||||||
|
} finally {
|
||||||
|
whBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deleteWebhook(w: Webhook) {
|
||||||
|
askConfirm({
|
||||||
|
title: 'Delete webhook',
|
||||||
|
message: `Stop sending booking events to ${w.url}?`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
action: async () => {
|
||||||
|
await request(`${base.value}/webhooks/${w._id}`, { method: 'DELETE' })
|
||||||
|
await loadWebhooks()
|
||||||
|
toast.ok('Webhook deleted')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function rotateWebhookSecret(w: Webhook) {
|
||||||
|
askConfirm({
|
||||||
|
title: 'Rotate signing secret',
|
||||||
|
message: 'The old secret stops working immediately. Update your receiver after rotating.',
|
||||||
|
confirmLabel: 'Rotate',
|
||||||
|
action: async () => {
|
||||||
|
await request(`${base.value}/webhooks/${w._id}/rotate-secret`, { method: 'POST' })
|
||||||
|
revealedSecrets[w._id] = true
|
||||||
|
await loadWebhooks()
|
||||||
|
toast.ok('Secret rotated', 'Copy the new signing secret now.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function copySecret(secret: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(secret)
|
||||||
|
toast.ok('Secret copied')
|
||||||
|
} catch {
|
||||||
|
toast.bad('Copy failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice(-4)}` : s)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -539,8 +636,89 @@ const detailTabs = computed(() => [
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhooks (tenant-level) -->
|
||||||
|
<section class="webhooks">
|
||||||
|
<div class="whhead">
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Integrations</Eyebrow>
|
||||||
|
<h2 class="whtitle">Webhooks</h2>
|
||||||
|
<p class="mute small">Receive signed POSTs when bookings are created, cancelled or rescheduled. Verify the HMAC-SHA256 signature in the <code>X-Dezky-Signature</code> header using the signing secret.</p>
|
||||||
|
</div>
|
||||||
|
<UiButton variant="secondary" @click="openWebhook()">
|
||||||
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||||
|
Add webhook
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="webhooksLoaded && !webhooks.length" class="notice">
|
||||||
|
No webhooks yet. Add one to forward booking lifecycle events to your own systems.
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-for="w in webhooks" :key="w._id" class="item">
|
||||||
|
<div class="itemmain">
|
||||||
|
<div class="ititle">
|
||||||
|
{{ w.url }}
|
||||||
|
<Badge :tone="w.active ? 'ok' : 'neutral'" dot>{{ w.active ? 'active' : 'paused' }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mute small whevents">
|
||||||
|
<Badge v-for="ev in w.events" :key="ev" tone="neutral">{{ ev }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="secretrow mute small">
|
||||||
|
<span class="secretlabel">Signing secret</span>
|
||||||
|
<code>{{ revealedSecrets[w._id] ? w.secret : maskSecret(w.secret) }}</code>
|
||||||
|
<button class="linkbtn" @click="revealedSecrets[w._id] = !revealedSecrets[w._id]">
|
||||||
|
{{ revealedSecrets[w._id] ? 'Hide' : 'Reveal' }}
|
||||||
|
</button>
|
||||||
|
<button class="linkbtn" @click="copySecret(w.secret)">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="itemactions">
|
||||||
|
<UiButton variant="ghost" @click="openWebhook(w)">Edit</UiButton>
|
||||||
|
<UiButton variant="ghost" @click="rotateWebhookSecret(w)">Rotate secret</UiButton>
|
||||||
|
<UiButton variant="ghost" @click="deleteWebhook(w)">Delete</UiButton>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook modal -->
|
||||||
|
<Modal
|
||||||
|
:open="whOpen"
|
||||||
|
eyebrow="Integrations"
|
||||||
|
:title="whEditingId ? 'Edit webhook' : 'Add webhook'"
|
||||||
|
size="md"
|
||||||
|
@close="whOpen = false"
|
||||||
|
>
|
||||||
|
<div class="form-stack">
|
||||||
|
<label class="field"><Eyebrow>Endpoint URL</Eyebrow>
|
||||||
|
<input class="input" v-model="whForm.url" placeholder="https://example.com/hooks/dezky" />
|
||||||
|
<span class="slughint" :class="{ bad: !!whForm.url && !whUrlValid }">
|
||||||
|
<template v-if="!whForm.url">We POST a signed JSON body here for each subscribed event.</template>
|
||||||
|
<template v-else-if="!whUrlValid">Enter a valid http(s) URL.</template>
|
||||||
|
<template v-else>Each delivery carries the <code>X-Dezky-Signature</code> HMAC header.</template>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="field"><Eyebrow>Events</Eyebrow>
|
||||||
|
<label v-for="ev in ALL_WEBHOOK_EVENTS" :key="ev" class="checkrow">
|
||||||
|
<input type="checkbox" :checked="whForm.events.includes(ev)" @change="toggleWhEvent(ev)" />
|
||||||
|
<code>{{ ev }}</code>
|
||||||
|
</label>
|
||||||
|
<span class="slughint" :class="{ bad: !whForm.events.length }" v-if="!whForm.events.length">Select at least one event.</span>
|
||||||
|
</div>
|
||||||
|
<label v-if="whEditingId" class="checkrow">
|
||||||
|
<input type="checkbox" v-model="whForm.active" />
|
||||||
|
Active (deliver events)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" @click="whOpen = false">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="whBusy || !whUrlValid || !whForm.events.length" @click="submitWebhook">
|
||||||
|
{{ whEditingId ? 'Save' : 'Create webhook' }}
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Add host modal -->
|
<!-- Add host modal -->
|
||||||
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
|
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
|
||||||
<div class="form-stack">
|
<div class="form-stack">
|
||||||
@@ -770,4 +948,14 @@ const detailTabs = computed(() => [
|
|||||||
.date { width: 100%; }
|
.date { width: 100%; }
|
||||||
.removeov { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text-mute); cursor: pointer; }
|
.removeov { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text-mute); cursor: pointer; }
|
||||||
.removeov:hover { background: rgba(226, 48, 48, 0.08); color: var(--bad); }
|
.removeov:hover { background: rgba(226, 48, 48, 0.08); color: var(--bad); }
|
||||||
|
.webhooks { margin-top: 32px; border-top: 1px solid var(--border); padding-top: 24px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.whhead { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 4px; }
|
||||||
|
.whtitle { font-size: 16px; font-weight: 600; margin: 4px 0 6px; }
|
||||||
|
.whhead p { max-width: 620px; }
|
||||||
|
.whevents { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.secretrow { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||||
|
.secretlabel { font-weight: 600; }
|
||||||
|
.checkrow { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.linkbtn { background: none; border: none; padding: 0; color: var(--text); font-size: 12px; text-decoration: underline; cursor: pointer; }
|
||||||
|
code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.t
|
|||||||
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
|
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
|
||||||
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
|
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
|
||||||
import { buildMeetUrl, meetJwtEnabled, type MeetJwtConfig } from './meet-room.js'
|
import { buildMeetUrl, meetJwtEnabled, type MeetJwtConfig } from './meet-room.js'
|
||||||
|
import { WebhooksService, toWebhookBookingView } from '../webhooks/webhooks.service.js'
|
||||||
|
|
||||||
const HOLD_MS = 10 * 60 * 1000
|
const HOLD_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export class BookingsService {
|
|||||||
private readonly provisioner: CredentialProvisioner,
|
private readonly provisioner: CredentialProvisioner,
|
||||||
private readonly gateway: JmapCalendarGateway,
|
private readonly gateway: JmapCalendarGateway,
|
||||||
private readonly mailer: JmapMailer,
|
private readonly mailer: JmapMailer,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
|
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
|
||||||
@@ -195,6 +197,9 @@ export class BookingsService {
|
|||||||
if (!written) {
|
if (!written) {
|
||||||
throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
|
throw new ServiceUnavailableException('Could not complete the booking on the calendar — please try again.')
|
||||||
}
|
}
|
||||||
|
// Booking is now confirmed with a calendar event — emit the lifecycle event.
|
||||||
|
// Fire-and-forget; webhook delivery must never block or break a booking.
|
||||||
|
void this.webhooks.dispatch('booking.created', toWebhookBookingView(booking))
|
||||||
return booking
|
return booking
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +339,7 @@ export class BookingsService {
|
|||||||
this.sendEmail(ctx, booking, access, 'cancellation').catch((e) =>
|
this.sendEmail(ctx, booking, access, 'cancellation').catch((e) =>
|
||||||
this.logger.warn(`Cancellation email failed: ${e.message}`),
|
this.logger.warn(`Cancellation email failed: ${e.message}`),
|
||||||
)
|
)
|
||||||
|
void this.webhooks.dispatch('booking.cancelled', toWebhookBookingView(booking))
|
||||||
return booking
|
return booking
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +378,9 @@ export class BookingsService {
|
|||||||
old.status = 'rescheduled'
|
old.status = 'rescheduled'
|
||||||
await old.save()
|
await old.save()
|
||||||
await this.lockModel.deleteOne({ hostId: old.hostId, startUtc: old.startUtc, bookingId: old._id }).exec()
|
await this.lockModel.deleteOne({ hostId: old.hostId, startUtc: old.startUtc, bookingId: old._id }).exec()
|
||||||
|
// The fresh booking already emitted 'booking.created'; emit 'booking.rescheduled'
|
||||||
|
// for it too (its rescheduledFromBookingId points at the old booking).
|
||||||
|
void this.webhooks.dispatch('booking.rescheduled', toWebhookBookingView(fresh))
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Host, HostSchema } from '../schemas/scheduling-host.schema.js'
|
|||||||
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
|
import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
|
||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
|
import { WebhookDelivery, WebhookDeliverySchema } from '../schemas/webhook-delivery.schema.js'
|
||||||
|
import { WebhookSubscription, WebhookSubscriptionSchema } from '../schemas/webhook-subscription.schema.js'
|
||||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||||
import { ABUSE_GUARD, abuseGuardFactory } from './abuse/abuse-guard.js'
|
import { ABUSE_GUARD, abuseGuardFactory } from './abuse/abuse-guard.js'
|
||||||
import { AvailabilityService } from './availability/availability.service.js'
|
import { AvailabilityService } from './availability/availability.service.js'
|
||||||
@@ -26,6 +28,9 @@ import { BookingReminderWorker } from './reminders/booking-reminder.worker.js'
|
|||||||
import { SchedulingAdminController } from './scheduling-admin.controller.js'
|
import { SchedulingAdminController } from './scheduling-admin.controller.js'
|
||||||
import { SlotService } from './slots/slot.service.js'
|
import { SlotService } from './slots/slot.service.js'
|
||||||
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.js'
|
import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.module.js'
|
||||||
|
import { WebhookDeliveryWorker } from './webhooks/webhook-delivery.worker.js'
|
||||||
|
import { WebhooksController } from './webhooks/webhooks.controller.js'
|
||||||
|
import { WebhooksService } from './webhooks/webhooks.service.js'
|
||||||
|
|
||||||
// dezky Scheduling — Calendly-style booking on top of Stalwart calendars. Public
|
// dezky Scheduling — Calendly-style booking on top of Stalwart calendars. Public
|
||||||
// pages (booking.dezky.eu) hit the unauthenticated /api/v1/public routes; host
|
// pages (booking.dezky.eu) hit the unauthenticated /api/v1/public routes; host
|
||||||
@@ -41,6 +46,8 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
|
|||||||
{ name: SlotLock.name, schema: SlotLockSchema },
|
{ name: SlotLock.name, schema: SlotLockSchema },
|
||||||
{ name: Tenant.name, schema: TenantSchema },
|
{ name: Tenant.name, schema: TenantSchema },
|
||||||
{ name: User.name, schema: UserSchema },
|
{ name: User.name, schema: UserSchema },
|
||||||
|
{ name: WebhookSubscription.name, schema: WebhookSubscriptionSchema },
|
||||||
|
{ name: WebhookDelivery.name, schema: WebhookDeliverySchema },
|
||||||
]),
|
]),
|
||||||
// Drives the @Cron booking reminder worker.
|
// Drives the @Cron booking reminder worker.
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
@@ -52,7 +59,7 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
|
|||||||
IntegrationsModule, // StalwartClient — host→account lookup during onboarding
|
IntegrationsModule, // StalwartClient — host→account lookup during onboarding
|
||||||
StalwartCalendarModule,
|
StalwartCalendarModule,
|
||||||
],
|
],
|
||||||
controllers: [SchedulingAdminController, PublicSchedulingController],
|
controllers: [WebhooksController, SchedulingAdminController, PublicSchedulingController],
|
||||||
providers: [
|
providers: [
|
||||||
HostsService,
|
HostsService,
|
||||||
AvailabilityService,
|
AvailabilityService,
|
||||||
@@ -63,6 +70,8 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
|
|||||||
JmapMailer,
|
JmapMailer,
|
||||||
BookingReminderWorker,
|
BookingReminderWorker,
|
||||||
CalendarRetryWorker,
|
CalendarRetryWorker,
|
||||||
|
WebhooksService,
|
||||||
|
WebhookDeliveryWorker,
|
||||||
// Pluggable captcha guard for the public booking surface (Turnstile when
|
// Pluggable captcha guard for the public booking surface (Turnstile when
|
||||||
// TURNSTILE_SECRET is set, otherwise a no-op).
|
// TURNSTILE_SECRET is set, otherwise a no-op).
|
||||||
{ provide: ABUSE_GUARD, useFactory: abuseGuardFactory, inject: [ConfigService] },
|
{ provide: ABUSE_GUARD, useFactory: abuseGuardFactory, inject: [ConfigService] },
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsOptional, IsUrl, MaxLength } from 'class-validator'
|
||||||
|
import { WEBHOOK_EVENTS, type WebhookEvent } from '../../../schemas/webhook-subscription.schema.js'
|
||||||
|
|
||||||
|
// Only allow https receivers (or http for local dev hostnames). Booking payloads
|
||||||
|
// carry attendee PII, so plain http to arbitrary hosts is rejected.
|
||||||
|
const URL_OPTS = { protocols: ['https', 'http'], require_protocol: true, require_tld: false }
|
||||||
|
|
||||||
|
export class CreateWebhookDto {
|
||||||
|
@IsUrl(URL_OPTS, { message: 'url must be a valid http(s) URL' })
|
||||||
|
@MaxLength(2048)
|
||||||
|
url!: string
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsIn(WEBHOOK_EVENTS, { each: true, message: `events must be one of: ${WEBHOOK_EVENTS.join(', ')}` })
|
||||||
|
events!: WebhookEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateWebhookDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl(URL_OPTS, { message: 'url must be a valid http(s) URL' })
|
||||||
|
@MaxLength(2048)
|
||||||
|
url?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsIn(WEBHOOK_EVENTS, { each: true, message: `events must be one of: ${WEBHOOK_EVENTS.join(', ')}` })
|
||||||
|
events?: WebhookEvent[]
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import {
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookDeliveryDocument,
|
||||||
|
} from '../../schemas/webhook-delivery.schema.js'
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
// Periodic worker that drains due 'pending' webhook deliveries. For each it POSTs
|
||||||
|
// the captured (already signed) payload to the receiver. A 2xx marks it
|
||||||
|
// 'delivered'; anything else (non-2xx, network error, timeout) increments
|
||||||
|
// attempts and either schedules an exponential-backoff retry or, once
|
||||||
|
// maxAttempts is hit, marks it terminally 'failed'. Each row is claimed
|
||||||
|
// atomically (status flip via findOneAndUpdate) so overlapping runs never
|
||||||
|
// double-send the same delivery.
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookDeliveryWorker {
|
||||||
|
private readonly logger = new Logger(WebhookDeliveryWorker.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(WebhookDelivery.name)
|
||||||
|
private readonly deliveryModel: Model<WebhookDeliveryDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE, { name: 'webhook-deliveries' })
|
||||||
|
async run(): Promise<void> {
|
||||||
|
const now = new Date()
|
||||||
|
const due = await this.deliveryModel
|
||||||
|
.find({ status: 'pending', nextAttemptAt: { $lte: now } })
|
||||||
|
.sort({ nextAttemptAt: 1 })
|
||||||
|
.limit(100)
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
for (const delivery of due) {
|
||||||
|
await this.attempt(delivery).catch((e) =>
|
||||||
|
this.logger.warn(`Webhook delivery ${delivery._id} errored: ${(e as Error).message}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attempt(delivery: WebhookDeliveryDocument): Promise<void> {
|
||||||
|
// Claim it: only proceed if it's still pending and due, bumping nextAttemptAt
|
||||||
|
// far out so a concurrent run won't pick it up while we're in flight.
|
||||||
|
const claim = await this.deliveryModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ _id: delivery._id, status: 'pending', nextAttemptAt: { $lte: new Date() } },
|
||||||
|
{ $set: { nextAttemptAt: new Date(Date.now() + REQUEST_TIMEOUT_MS + 60_000) } },
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
if (!claim) return // another run claimed it
|
||||||
|
|
||||||
|
const attempts = claim.attempts + 1
|
||||||
|
try {
|
||||||
|
const res = await this.post(claim.url, claim.payload, claim.signature, claim.event)
|
||||||
|
if (res.ok) {
|
||||||
|
await this.deliveryModel
|
||||||
|
.updateOne(
|
||||||
|
{ _id: claim._id },
|
||||||
|
{ $set: { status: 'delivered', attempts, deliveredAt: new Date(), lastStatusCode: res.status, lastError: undefined } },
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
this.logger.log(`Delivered webhook ${claim._id} (${claim.event}) → ${claim.url} [${res.status}]`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.fail(claim._id, attempts, claim.maxAttempts, `HTTP ${res.status}`, res.status)
|
||||||
|
} catch (err) {
|
||||||
|
await this.fail(claim._id, attempts, claim.maxAttempts, (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(
|
||||||
|
url: string,
|
||||||
|
body: string,
|
||||||
|
signature: string,
|
||||||
|
event: string,
|
||||||
|
): Promise<{ ok: boolean; status: number }> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': 'dezky-webhooks/1',
|
||||||
|
'x-dezky-event': event,
|
||||||
|
'X-Dezky-Signature': `sha256=${signature}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
return { ok: res.ok, status: res.status }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fail(
|
||||||
|
id: unknown,
|
||||||
|
attempts: number,
|
||||||
|
maxAttempts: number,
|
||||||
|
error: string,
|
||||||
|
statusCode?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
await this.deliveryModel
|
||||||
|
.updateOne({ _id: id as any }, { $set: { status: 'failed', attempts, lastError: error, lastStatusCode: statusCode } })
|
||||||
|
.exec()
|
||||||
|
this.logger.error(`Webhook ${id} failed terminally after ${attempts} attempts: ${error}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Exponential backoff capped at ~1h: 2^attempts minutes.
|
||||||
|
const backoffMs = Math.min(2 ** attempts, 60) * 60_000
|
||||||
|
await this.deliveryModel
|
||||||
|
.updateOne(
|
||||||
|
{ _id: id as any },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
status: 'pending',
|
||||||
|
attempts,
|
||||||
|
lastError: error,
|
||||||
|
lastStatusCode: statusCode,
|
||||||
|
nextAttemptAt: new Date(Date.now() + backoffMs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
this.logger.warn(`Webhook ${id} attempt ${attempts}/${maxAttempts} failed (${error}); retrying in ${backoffMs / 60_000}m`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { Types } from 'mongoose'
|
||||||
|
import { ActorService } from '../../auth/actor.service.js'
|
||||||
|
import { CurrentUser } from '../../auth/current-user.decorator.js'
|
||||||
|
import { JwtAuthGuard } from '../../auth/jwt-auth.guard.js'
|
||||||
|
import type { AuthentikJwtPayload } from '../../auth/jwt-payload.interface.js'
|
||||||
|
import { TenantsService } from '../../tenants/tenants.service.js'
|
||||||
|
import { CreateWebhookDto, UpdateWebhookDto } from './dto/webhook-dtos.js'
|
||||||
|
import { WebhooksService } from './webhooks.service.js'
|
||||||
|
|
||||||
|
// Tenant webhook administration. Same base path + gating as the rest of the
|
||||||
|
// scheduling admin surface (platformAdmin OR a member of the tenant). The
|
||||||
|
// signing secret is returned in full on create/get/rotate so the tenant can
|
||||||
|
// configure their receiver's HMAC verification.
|
||||||
|
@Controller('api/v1/tenants/:slug/scheduling/webhooks')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(
|
||||||
|
private readonly actor: ActorService,
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return tenant._id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.webhooks.list(await this.gate(slug, jwt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Param('slug') slug: string, @Body() dto: CreateWebhookDto, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.webhooks.create(await this.gate(slug, jwt), dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateWebhookDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
) {
|
||||||
|
return this.webhooks.update(await this.gate(slug, jwt), id, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rotate-secret')
|
||||||
|
async rotateSecret(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.webhooks.rotateSecret(await this.gate(slug, jwt), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/deliveries')
|
||||||
|
async deliveries(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.webhooks.listDeliveries(await this.gate(slug, jwt), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async remove(@Param('slug') slug: string, @Param('id') id: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
await this.webhooks.remove(await this.gate(slug, jwt), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { createHmac, randomBytes } from 'node:crypto'
|
||||||
|
import { Model, Types } from 'mongoose'
|
||||||
|
import type { BookingDocument } from '../../schemas/booking.schema.js'
|
||||||
|
import {
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookDeliveryDocument,
|
||||||
|
} from '../../schemas/webhook-delivery.schema.js'
|
||||||
|
import {
|
||||||
|
WebhookSubscription,
|
||||||
|
WebhookSubscriptionDocument,
|
||||||
|
type WebhookEvent,
|
||||||
|
} from '../../schemas/webhook-subscription.schema.js'
|
||||||
|
import type { CreateWebhookDto, UpdateWebhookDto } from './dto/webhook-dtos.js'
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 8
|
||||||
|
|
||||||
|
// Minimal, booking-agnostic view of a booking the dispatcher serialises into the
|
||||||
|
// webhook payload. Keeping this a plain interface (not the Mongoose document)
|
||||||
|
// keeps BookingsService decoupled from the webhook wire format.
|
||||||
|
export interface WebhookBookingView {
|
||||||
|
id: string
|
||||||
|
tenantId: Types.ObjectId
|
||||||
|
hostId: Types.ObjectId
|
||||||
|
eventTypeId: Types.ObjectId
|
||||||
|
status: string
|
||||||
|
startUtc: Date
|
||||||
|
endUtc: Date
|
||||||
|
attendeeName: string
|
||||||
|
attendeeEmail: string
|
||||||
|
attendeeTimezone: string
|
||||||
|
attendeeNotes?: string
|
||||||
|
locationType?: string
|
||||||
|
locationUrl?: string
|
||||||
|
cancellationReason?: string
|
||||||
|
rescheduledFromBookingId?: Types.ObjectId | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a persisted booking document to the wire view. Exported so callers
|
||||||
|
// (BookingsService) can hand us a plain view without us importing their types.
|
||||||
|
export function toWebhookBookingView(b: BookingDocument): WebhookBookingView {
|
||||||
|
return {
|
||||||
|
id: String(b._id),
|
||||||
|
tenantId: b.tenantId,
|
||||||
|
hostId: b.hostId,
|
||||||
|
eventTypeId: b.eventTypeId,
|
||||||
|
status: b.status,
|
||||||
|
startUtc: b.startUtc,
|
||||||
|
endUtc: b.endUtc,
|
||||||
|
attendeeName: b.attendeeName,
|
||||||
|
attendeeEmail: b.attendeeEmail,
|
||||||
|
attendeeTimezone: b.attendeeTimezone,
|
||||||
|
attendeeNotes: b.attendeeNotes,
|
||||||
|
locationType: b.locationType,
|
||||||
|
locationUrl: b.locationUrl,
|
||||||
|
cancellationReason: b.cancellationReason,
|
||||||
|
rescheduledFromBookingId: b.rescheduledFromBookingId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant webhooks for booking lifecycle. Admin CRUD over subscriptions plus a
|
||||||
|
// fire-and-forget `dispatch` that fans an event out to every active matching
|
||||||
|
// subscription by persisting one signed WebhookDelivery per subscription. The
|
||||||
|
// actual HTTP POSTs (with retries) are driven asynchronously by the
|
||||||
|
// WebhookDeliveryWorker so booking flows never block on a slow receiver.
|
||||||
|
@Injectable()
|
||||||
|
export class WebhooksService {
|
||||||
|
private readonly logger = new Logger(WebhooksService.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(WebhookSubscription.name)
|
||||||
|
private readonly subModel: Model<WebhookSubscriptionDocument>,
|
||||||
|
@InjectModel(WebhookDelivery.name)
|
||||||
|
private readonly deliveryModel: Model<WebhookDeliveryDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Admin CRUD ─────────────────────────────────────────────────────────────
|
||||||
|
list(tenantId: Types.ObjectId): Promise<WebhookSubscriptionDocument[]> {
|
||||||
|
return this.subModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: Types.ObjectId, dto: CreateWebhookDto): Promise<WebhookSubscriptionDocument> {
|
||||||
|
const existing = await this.subModel.findOne({ tenantId, url: dto.url }).exec()
|
||||||
|
if (existing) throw new ConflictException('A webhook with that URL already exists.')
|
||||||
|
return this.subModel.create({
|
||||||
|
tenantId,
|
||||||
|
url: dto.url,
|
||||||
|
events: dto.events,
|
||||||
|
secret: generateSecret(),
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: Types.ObjectId, id: string, dto: UpdateWebhookDto): Promise<WebhookSubscriptionDocument> {
|
||||||
|
const sub = await this.getOwned(tenantId, id)
|
||||||
|
if (dto.url !== undefined) sub.url = dto.url
|
||||||
|
if (dto.events !== undefined) sub.events = dto.events
|
||||||
|
if (dto.active !== undefined) sub.active = dto.active
|
||||||
|
return sub.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateSecret(tenantId: Types.ObjectId, id: string): Promise<WebhookSubscriptionDocument> {
|
||||||
|
const sub = await this.getOwned(tenantId, id)
|
||||||
|
sub.secret = generateSecret()
|
||||||
|
return sub.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: Types.ObjectId, id: string): Promise<void> {
|
||||||
|
const sub = await this.getOwned(tenantId, id)
|
||||||
|
await this.subModel.deleteOne({ _id: sub._id }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent deliveries for a subscription (admin observability).
|
||||||
|
async listDeliveries(tenantId: Types.ObjectId, id: string): Promise<WebhookDeliveryDocument[]> {
|
||||||
|
await this.getOwned(tenantId, id)
|
||||||
|
return this.deliveryModel
|
||||||
|
.find({ tenantId, subscriptionId: new Types.ObjectId(id) })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(50)
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwned(tenantId: Types.ObjectId, id: string): Promise<WebhookSubscriptionDocument> {
|
||||||
|
if (!Types.ObjectId.isValid(id)) throw new NotFoundException('Webhook not found')
|
||||||
|
const sub = await this.subModel.findById(id).exec()
|
||||||
|
if (!sub) throw new NotFoundException('Webhook not found')
|
||||||
|
if (!sub.tenantId.equals(tenantId)) throw new ForbiddenException('No access to this webhook')
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatch (called from BookingsService) ─────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Fan an event out to every active subscription for the tenant that listens
|
||||||
|
* for it, by persisting one signed WebhookDelivery each. Best-effort and
|
||||||
|
* non-throwing: callers (booking flows) should `.catch()` defensively but a
|
||||||
|
* failure here must never break a booking. Returns the number of deliveries
|
||||||
|
* enqueued.
|
||||||
|
*/
|
||||||
|
async dispatch(event: WebhookEvent, booking: WebhookBookingView): Promise<number> {
|
||||||
|
try {
|
||||||
|
const subs = await this.subModel
|
||||||
|
.find({ tenantId: booking.tenantId, active: true, events: event })
|
||||||
|
.exec()
|
||||||
|
if (subs.length === 0) return 0
|
||||||
|
|
||||||
|
const sentAt = new Date()
|
||||||
|
let enqueued = 0
|
||||||
|
for (const sub of subs) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
event,
|
||||||
|
sentAt: sentAt.toISOString(),
|
||||||
|
subscriptionId: String(sub._id),
|
||||||
|
data: serialiseBooking(booking),
|
||||||
|
})
|
||||||
|
const signature = sign(sub.secret, payload)
|
||||||
|
await this.deliveryModel.create({
|
||||||
|
tenantId: booking.tenantId,
|
||||||
|
subscriptionId: sub._id,
|
||||||
|
event,
|
||||||
|
url: sub.url,
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
status: 'pending',
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: MAX_ATTEMPTS,
|
||||||
|
nextAttemptAt: sentAt,
|
||||||
|
})
|
||||||
|
enqueued += 1
|
||||||
|
}
|
||||||
|
return enqueued
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Webhook dispatch for "${event}" failed: ${(err as Error).message}`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex HMAC-SHA256 of the body under the subscription secret.
|
||||||
|
export function sign(secret: string, body: string): string {
|
||||||
|
return createHmac('sha256', secret).update(body).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSecret(): string {
|
||||||
|
return `whsec_${randomBytes(24).toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public-safe booking projection for the payload. No manage token / internal
|
||||||
|
// calendar ids — just the booking facts a receiver needs.
|
||||||
|
function serialiseBooking(b: WebhookBookingView) {
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
hostId: String(b.hostId),
|
||||||
|
eventTypeId: String(b.eventTypeId),
|
||||||
|
status: b.status,
|
||||||
|
startUtc: b.startUtc.toISOString(),
|
||||||
|
endUtc: b.endUtc.toISOString(),
|
||||||
|
attendeeName: b.attendeeName,
|
||||||
|
attendeeEmail: b.attendeeEmail,
|
||||||
|
attendeeTimezone: b.attendeeTimezone,
|
||||||
|
attendeeNotes: b.attendeeNotes ?? null,
|
||||||
|
locationType: b.locationType ?? null,
|
||||||
|
locationUrl: b.locationUrl ?? null,
|
||||||
|
cancellationReason: b.cancellationReason ?? null,
|
||||||
|
rescheduledFromBookingId: b.rescheduledFromBookingId ? String(b.rescheduledFromBookingId) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
import type { WebhookEvent } from './webhook-subscription.schema.js'
|
||||||
|
|
||||||
|
export type WebhookDeliveryDocument = HydratedDocument<WebhookDelivery>
|
||||||
|
|
||||||
|
// 'pending' — queued, waiting for the delivery worker (nextAttemptAt due).
|
||||||
|
// 'delivered' — receiver returned 2xx.
|
||||||
|
// 'failed' — exhausted maxAttempts; terminal.
|
||||||
|
export type WebhookDeliveryStatus = 'pending' | 'delivered' | 'failed'
|
||||||
|
|
||||||
|
// One outbound delivery attempt-set for a single (subscription, event) pair. The
|
||||||
|
// payload + signature are captured at enqueue time so retries replay the exact
|
||||||
|
// same signed body. The @nestjs/schedule worker claims due 'pending' rows,
|
||||||
|
// POSTs them, and on non-2xx schedules an exponential backoff retry until
|
||||||
|
// maxAttempts is reached.
|
||||||
|
@Schema({ collection: 'scheduling_webhook_deliveries', timestamps: true })
|
||||||
|
export class WebhookDelivery {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||||
|
tenantId!: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'WebhookSubscription', required: true, index: true })
|
||||||
|
subscriptionId!: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
event!: WebhookEvent
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
url!: string
|
||||||
|
|
||||||
|
// The exact JSON body string that was signed (so retries are byte-identical).
|
||||||
|
@Prop({ required: true })
|
||||||
|
payload!: string
|
||||||
|
|
||||||
|
// Hex HMAC-SHA256 of `payload` under the subscription secret. Sent as the
|
||||||
|
// X-Dezky-Signature header.
|
||||||
|
@Prop({ required: true })
|
||||||
|
signature!: string
|
||||||
|
|
||||||
|
@Prop({ enum: ['pending', 'delivered', 'failed'], default: 'pending', index: true })
|
||||||
|
status!: WebhookDeliveryStatus
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
attempts!: number
|
||||||
|
|
||||||
|
@Prop({ required: true, default: 8 })
|
||||||
|
maxAttempts!: number
|
||||||
|
|
||||||
|
// When the next attempt is due. The worker polls for due 'pending' rows.
|
||||||
|
@Prop({ required: true, index: true })
|
||||||
|
nextAttemptAt!: Date
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
lastError?: string
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
lastStatusCode?: number
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
deliveredAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebhookDeliverySchema = SchemaFactory.createForClass(WebhookDelivery)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type WebhookSubscriptionDocument = HydratedDocument<WebhookSubscription>
|
||||||
|
|
||||||
|
// Booking lifecycle events a tenant can subscribe to. Kept in sync with the
|
||||||
|
// event names WebhookService.dispatch emits from BookingsService.
|
||||||
|
export type WebhookEvent = 'booking.created' | 'booking.cancelled' | 'booking.rescheduled'
|
||||||
|
|
||||||
|
export const WEBHOOK_EVENTS: WebhookEvent[] = ['booking.created', 'booking.cancelled', 'booking.rescheduled']
|
||||||
|
|
||||||
|
// A tenant-configured HTTPS endpoint that receives signed POSTs for booking
|
||||||
|
// lifecycle events. The `secret` is the HMAC-SHA256 key used to sign each
|
||||||
|
// delivery body (header X-Dezky-Signature); the tenant stores the same secret to
|
||||||
|
// verify. Inactive subscriptions are skipped by the dispatcher.
|
||||||
|
@Schema({ collection: 'scheduling_webhook_subscriptions', timestamps: true })
|
||||||
|
export class WebhookSubscription {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
|
||||||
|
tenantId!: Types.ObjectId
|
||||||
|
|
||||||
|
@Prop({ required: true, trim: true })
|
||||||
|
url!: string
|
||||||
|
|
||||||
|
// HMAC signing secret. Generated server-side; surfaced to the admin once on
|
||||||
|
// create/rotate so they can configure their receiver's verification.
|
||||||
|
@Prop({ required: true })
|
||||||
|
secret!: string
|
||||||
|
|
||||||
|
@Prop({ type: [String], enum: WEBHOOK_EVENTS, default: WEBHOOK_EVENTS })
|
||||||
|
events!: WebhookEvent[]
|
||||||
|
|
||||||
|
@Prop({ default: true })
|
||||||
|
active!: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebhookSubscriptionSchema = SchemaFactory.createForClass(WebhookSubscription)
|
||||||
Reference in New Issue
Block a user