feat(scheduling): tenant webhooks for booking lifecycle

This commit is contained in:
Ronni Baslund
2026-06-07 09:08:45 +02:00
parent e33b7f18a3
commit b9b4d56a2d
9 changed files with 758 additions and 1 deletions
+188
View File
@@ -441,6 +441,103 @@ const detailTabs = computed(() => [
{ value: 'availability', label: 'Availability', count: availability.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>
<template>
@@ -539,8 +636,89 @@ const detailTabs = computed(() => [
</template>
</section>
</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>
<!-- 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 -->
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
<div class="form-stack">
@@ -770,4 +948,14 @@ const detailTabs = computed(() => [
.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: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>