Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions apps/sim/app/api/demo-requests/bookings/route.ts
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,
Expand All @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

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 bookingUid can schedule another sales notification because reviewDemoBookingRequest returns 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/app/api/demo-requests/bookings/route.ts, line 102:

<comment>Repeated Cal.com deliveries for the same `bookingUid` can schedule another sales notification because `reviewDemoBookingRequest` returns 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.</comment>

<file context>
@@ -86,10 +89,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
+
+  // 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({
</file context>
Fix with cubic

after(() =>
notifyDemoRequest({
type: 'demo_request.booking',
bookingUid: uid,
Comment on lines +102 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Booking notifications repeat

Every repeated Cal.com delivery sends demo_request.booking again for the same bookingUid. claimDemoBooker remembers the original lookup, but it does not stop another notification. An unverified booking stays pending on every delivery, so each retry sends another sales event and can create duplicate cards or manual review work.

Add a separate notification claim keyed by bookingUid to filter repeated deliveries.

email: attendee.email,
name: attendee.name ?? null,
startTime: startTime ?? null,
decision,
})
)
}
return NextResponse.json<WebhookResponse>({ ok: true, decision })
})
21 changes: 18 additions & 3 deletions apps/sim/app/api/demo-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'}
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/api/contracts/demo-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions apps/sim/lib/demo-booking/notify.ts
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) {

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/demo-booking/notify.ts, line 57:

<comment>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.</comment>

<file context>
@@ -0,0 +1,66 @@
+      body: JSON.stringify(event),
+      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+    })
+    if (!response.ok) {
+      logger.warn('Demo request webhook rejected the event', {
+        type: event.type,
</file context>
Suggested change
if (!response.ok) {
await response.body?.cancel()
if (!response.ok) {
Fix with cubic

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 })
}
}
2 changes: 1 addition & 1 deletion apps/sim/lib/demo-booking/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading