Repository navigation
feat(demo-booking): send demo requests and booking decisions to a sales-channel webhook #10015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { safeCompare } from '@sim/security/compare' | ||
| import { hmacSha256Hex } from '@sim/security/hmac' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { after, type NextRequest, NextResponse } from 'next/server' | ||
| import { | ||
| calBookingRequestSchema, | ||
| type calBookingWebhookContract, | ||
|
|
@@ -15,6 +15,7 @@ import { | |
| readStreamToBufferWithLimit, | ||
| } from '@/lib/core/utils/stream-limits' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { notifyDemoRequest } from '@/lib/demo-booking/notify' | ||
| import { reviewDemoBookingRequest } from '@/lib/demo-booking/review' | ||
| import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' | ||
|
|
||
|
|
@@ -44,7 +45,9 @@ async function readBody(request: NextRequest): Promise<string> { | |
| * Subscribe it to `BOOKING_REQUESTED` on that event type only, signed with | ||
| * `CAL_WEBHOOK_SECRET`. The signature is an HMAC-SHA256 hex digest of the raw | ||
| * body in `x-cal-signature-256`. {@link reviewDemoBookingRequest} confirms, | ||
| * declines, or leaves each request for manual review. | ||
| * declines, or leaves each request for manual review, and every booking it does | ||
| * not decline is reported to the sales channel, where a pending one is | ||
| * confirmed or declined by hand. | ||
| */ | ||
| export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| const secret = env.CAL_WEBHOOK_SECRET | ||
|
|
@@ -86,10 +89,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | |
| return NextResponse.json(ACKNOWLEDGED) | ||
| } | ||
|
|
||
| const { uid, startTime, attendees } = booking.data | ||
| const attendee = attendees[0] | ||
| const decision = await reviewDemoBookingRequest({ | ||
| bookingUid: booking.data.uid, | ||
| attendeeEmail: booking.data.attendees[0].email, | ||
| bookingUid: uid, | ||
| attendeeEmail: attendee.email, | ||
| }) | ||
| logger.info('Demo booking request reviewed', { decision }) | ||
|
|
||
| // A declined booking had no form submission behind it, which is what a bot | ||
| // booking the public link looks like, so the sales channel never hears of it. | ||
| if (decision !== 'declined') { | ||
| after(() => | ||
| notifyDemoRequest({ | ||
| type: 'demo_request.booking', | ||
| bookingUid: uid, | ||
|
Comment on lines
+102
to
+106
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every repeated Cal.com delivery sends Add a separate notification claim keyed by |
||
| email: attendee.email, | ||
| name: attendee.name ?? null, | ||
| startTime: startTime ?? null, | ||
| decision, | ||
| }) | ||
| ) | ||
| } | ||
| return NextResponse.json<WebhookResponse>({ ok: true, decision }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,66 @@ | ||||||||
| import { createLogger } from '@sim/logger' | ||||||||
| import { env } from '@/lib/core/config/env' | ||||||||
| import type { DemoBookerStatus } from '@/lib/demo-booking/bookers' | ||||||||
| import type { DemoBookingDecision } from '@/lib/demo-booking/review' | ||||||||
|
|
||||||||
| const logger = createLogger('DemoRequestNotify') | ||||||||
|
|
||||||||
| const REQUEST_TIMEOUT_MS = 10_000 | ||||||||
|
|
||||||||
| /** A demo form submission, sent once the visitor's record is saved. */ | ||||||||
| interface DemoRequestSubmittedEvent { | ||||||||
| type: 'demo_request.submitted' | ||||||||
| email: string | ||||||||
| name: string | ||||||||
| phone: string | null | ||||||||
| companySize: string | ||||||||
| details: string | ||||||||
| attribution: string | null | ||||||||
| verification: DemoBookerStatus | ||||||||
| submittedAt: string | ||||||||
| } | ||||||||
|
|
||||||||
| /** The decision on a requested booking, sent after the review has acted on it. */ | ||||||||
| interface DemoBookingDecidedEvent { | ||||||||
| type: 'demo_request.booking' | ||||||||
| bookingUid: string | ||||||||
| email: string | ||||||||
| name: string | null | ||||||||
| startTime: string | null | ||||||||
| decision: DemoBookingDecision | ||||||||
| } | ||||||||
|
|
||||||||
| type DemoRequestEvent = DemoRequestSubmittedEvent | DemoBookingDecidedEvent | ||||||||
|
|
||||||||
| /** | ||||||||
| * Sends a demo request event to `DEMO_REQUEST_WEBHOOK_URL`, the workflow that | ||||||||
| * posts each request to the sales channel and lets the team confirm or decline | ||||||||
| * a pending booking there. Best effort: a failure is logged and never reaches | ||||||||
| * the visitor or Cal.com, and with no URL configured nothing is sent. | ||||||||
| */ | ||||||||
| export async function notifyDemoRequest(event: DemoRequestEvent): Promise<void> { | ||||||||
| const url = env.DEMO_REQUEST_WEBHOOK_URL | ||||||||
| if (!url) return | ||||||||
|
|
||||||||
| const headers: Record<string, string> = { 'Content-Type': 'application/json' } | ||||||||
| if (env.DEMO_REQUEST_WEBHOOK_TOKEN) { | ||||||||
| headers.Authorization = `Bearer ${env.DEMO_REQUEST_WEBHOOK_TOKEN}` | ||||||||
| } | ||||||||
|
|
||||||||
| try { | ||||||||
| const response = await fetch(url, { | ||||||||
| method: 'POST', | ||||||||
| headers, | ||||||||
| body: JSON.stringify(event), | ||||||||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||||||||
| }) | ||||||||
| if (!response.ok) { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The response body is never consumed or cancelled, so each send can hold its connection until GC. Cancel the body before checking the status, as the connector clients already do. Prompt for AI agents
Suggested change
|
||||||||
| logger.warn('Demo request webhook rejected the event', { | ||||||||
| type: event.type, | ||||||||
| status: response.status, | ||||||||
| }) | ||||||||
| } | ||||||||
| } catch (error) { | ||||||||
| logger.warn('Demo request webhook failed', { type: event.type, error }) | ||||||||
| } | ||||||||
| } | ||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Repeated Cal.com deliveries for the same
bookingUidcan schedule another sales notification becausereviewDemoBookingRequestreturns the stored claim but this branch does not distinguish first from repeated processing. Deduplicate booking notifications by claim/booking ID before scheduling the webhook event.Prompt for AI agents