From 5720f641413062f049c1d62f21a7efe1a22654f3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 13:27:11 -0800 Subject: [PATCH] feat(demo-booking): send demo requests and booking decisions to a sales-channel webhook --- .../app/api/demo-requests/bookings/route.ts | 28 ++++++-- apps/sim/app/api/demo-requests/route.ts | 21 +++++- apps/sim/lib/api/contracts/demo-requests.ts | 5 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/demo-booking/notify.ts | 66 +++++++++++++++++++ apps/sim/lib/demo-booking/review.ts | 2 +- 6 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/demo-booking/notify.ts diff --git a/apps/sim/app/api/demo-requests/bookings/route.ts b/apps/sim/app/api/demo-requests/bookings/route.ts index e016e4f8d9d..aede785dbcb 100644 --- a/apps/sim/app/api/demo-requests/bookings/route.ts +++ b/apps/sim/app/api/demo-requests/bookings/route.ts @@ -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 { * 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, + email: attendee.email, + name: attendee.name ?? null, + startTime: startTime ?? null, + decision, + }) + ) + } return NextResponse.json({ ok: true, decision }) }) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index ca1dc395219..b5904773e03 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -14,6 +14,7 @@ import { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type DemoBookerStatus, recordDemoBooker } from '@/lib/demo-booking/bookers' +import { notifyDemoRequest } from '@/lib/demo-booking/notify' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -41,8 +42,8 @@ const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again * their Cal.com booking (see {@link recordDemoBooker} and * `lib/demo-booking/review.ts`). The form opens the calendar only after this * answers, so a booking can never outrun its record; a failed write answers 503 - * and the visitor retries instead of booking unrecorded. The sales notification - * goes out after the response. + * and the visitor retries instead of booking unrecorded. The sales email and the + * sales-channel event ({@link notifyDemoRequest}) go out after the response. * * The captcha mirrors `/api/contact`: a valid Turnstile token marks the email * `verified`; a missing token or a Cloudflare transport error passes through the @@ -140,8 +141,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { companySize, }) + const submittedAt = new Date().toISOString() const emailText = `Demo request submitted -Submitted: ${new Date().toISOString()} +Submitted: ${submittedAt} Name: ${firstName} ${lastName} Email: ${companyEmail} Phone: ${phoneNumber ?? 'Not provided'} @@ -165,6 +167,19 @@ ${attribution ? `\nAttribution:\n${attribution}\n` : ''}` if (emailResult.success) logger.info('Demo request email sent successfully') else logger.error('Error sending demo request email', emailResult.message) }) + after(() => + notifyDemoRequest({ + type: 'demo_request.submitted', + email: companyEmail, + name: `${firstName} ${lastName}`, + phone: phoneNumber ?? null, + companySize: getDemoRequestCompanySizeLabel(companySize), + details, + attribution: attribution || null, + verification: bookerStatus, + submittedAt, + }) + ) return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } catch (error) { diff --git a/apps/sim/lib/api/contracts/demo-requests.ts b/apps/sim/lib/api/contracts/demo-requests.ts index 34fc5e84196..0c665641ba4 100644 --- a/apps/sim/lib/api/contracts/demo-requests.ts +++ b/apps/sim/lib/api/contracts/demo-requests.ts @@ -94,10 +94,11 @@ export const calWebhookEnvelopeSchema = z.object({ payload: z.unknown().optional(), }) -/** The fields of a requested booking that the demo booking review reads. */ +/** The fields of a requested booking that the review and the sales notification read. */ export const calBookingRequestSchema = z.object({ uid: z.string().min(1), - attendees: z.array(z.object({ email: z.string().min(1) })).min(1), + startTime: z.string().optional(), + attendees: z.array(z.object({ email: z.string().min(1), name: z.string().optional() })).min(1), }) /** diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5606ea7b2c2..d8600e21c27 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -146,6 +146,8 @@ export const env = createEnv({ TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification CAL_API_KEY: z.string().min(1).optional(), // Cal.com API key that confirms or declines /demo booking requests (unset leaves every request for manual review) CAL_WEBHOOK_SECRET: z.string().min(1).optional(), // Secret Cal.com signs /demo booking webhooks with (unset disables the webhook) + DEMO_REQUEST_WEBHOOK_URL: z.string().url().optional(), // Webhook that receives demo requests and booking decisions for the sales channel (unset sends none) + DEMO_REQUEST_WEBHOOK_TOKEN: z.string().min(1).optional(), // Bearer token for DEMO_REQUEST_WEBHOOK_URL ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Opt-out: OAuth access/refresh tokens are sealed on write (read by @sim/db) and backfilled by migrate whenever ENCRYPTION_KEY is 64 hex characters; false or 0 writes plaintext. Sealed tokens need v0.9.33+ on every process that reads them diff --git a/apps/sim/lib/demo-booking/notify.ts b/apps/sim/lib/demo-booking/notify.ts new file mode 100644 index 00000000000..3692c1b326a --- /dev/null +++ b/apps/sim/lib/demo-booking/notify.ts @@ -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 { + const url = env.DEMO_REQUEST_WEBHOOK_URL + if (!url) return + + const headers: Record = { '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) { + 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 }) + } +} diff --git a/apps/sim/lib/demo-booking/review.ts b/apps/sim/lib/demo-booking/review.ts index ef93a53f471..a9f8b3d481e 100644 --- a/apps/sim/lib/demo-booking/review.ts +++ b/apps/sim/lib/demo-booking/review.ts @@ -18,7 +18,7 @@ const DECLINE_REASON = `Please book your demo through ${SIM_SITE_URL}/demo.` * unconfirmed list for the team to review by hand, so it never reaches a * calendar unless someone approves it. */ -type DemoBookingDecision = 'confirmed' | 'declined' | 'pending' +export type DemoBookingDecision = 'confirmed' | 'declined' | 'pending' interface DemoBookingRequest { bookingUid: string