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