-
Notifications
You must be signed in to change notification settings - Fork 11.6k
feat: cal.ai self serve #21827
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
Closed
Closed
feat: cal.ai self serve #21827
Changes from all commits
Commits
Show all changes
82 commits
Select commit
Hold shift + click to select a range
5cac626
feat: cal.ai self serve
Udit-takkar e38f713
chore: progress
Udit-takkar f52c497
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar b6be1fd
fix: form
Udit-takkar e7a61c4
fix: links[endpoint] error
Udit-takkar d2ad1cf
chore: form
Udit-takkar ca041da
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 049c1bb
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 3b9cf80
feat: finish setup
Udit-takkar def1186
chore: fix
Udit-takkar 8a5967b
fix: trpc hang bug
Udit-takkar b1c0524
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 436301c
chore: save progress
Udit-takkar e9c3c05
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 254a2e0
feat: add phone number billing
Udit-takkar 807d515
feat: retell ai webhook
Udit-takkar 6771fa7
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 39cd886
fix: type error
Udit-takkar a27a800
feat: remove api key input
Udit-takkar 9676ef3
feat: add delete
Udit-takkar fb852c2
feat: add delete logic and assign phone number
Udit-takkar 6d06d74
refactor: use design pattern
Udit-takkar 7106fca
chore: remove comment
Udit-takkar bf7b9c9
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar 610bd75
fix: type errror
Udit-takkar 95f82aa
refactor: decouple retell ai
Udit-takkar b79c4b5
fix: name
Udit-takkar 130e38c
chore: comment unued
Udit-takkar 7ff4667
fix: type errors
Udit-takkar d29eeb9
fix: type error
Udit-takkar e6700d2
feat: import phone number functionality
Udit-takkar 47d4ad7
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar cdaeae4
feat: wwebhook handler
Udit-takkar 8791002
fix: type errors
Udit-takkar d9baa63
fix: type error
Udit-takkar 443fa77
fix: 404 new trpc endpoint bug
Udit-takkar 82cca61
fix: impor
Udit-takkar b1b9aac
chore
Udit-takkar 9be39b8
feat: new version (wip)
Udit-takkar 0754d7b
chore: improvements
Udit-takkar 2701675
tests: add unit tests
Udit-takkar 470f034
fix: types
Udit-takkar 20bcd41
feat: new desing
Udit-takkar 0b9f2b8
refactor: move everything to service
Udit-takkar 351c4db
tests: add unit tests for service and client
Udit-takkar 0ad600a
fix: deleting cal ai action and workflow
Udit-takkar b0d50a7
chore: update
Udit-takkar e7a804d
refactor: improve code
Udit-takkar 9b9ef44
chore: use new app route
Udit-takkar 326621a
fix: infinite rendering bug
Udit-takkar feb6236
feat: add team support completely
Udit-takkar 1df875e
chore: remove old design and unused
Udit-takkar 3146566
perf: improve agent repository query
Udit-takkar f50049e
perf: improve query
Udit-takkar faebeb1
chore: improvements and rate limiting
Udit-takkar 4c62e65
fix: credit reposiotory
Udit-takkar 8d8ff2d
fix: update unit tests and type error
Udit-takkar 62bbd3f
fix: type
Udit-takkar f9886a2
chore: types
Udit-takkar 5253927
fix: use types from retell sdk
Udit-takkar 9301519
chore: remove PhoneData
Udit-takkar 81f0145
chore: code improvements
Udit-takkar e97d2ed
chore: code improvements
Udit-takkar c1791b8
fix: tests and types
Udit-takkar b34588e
chore: improve UI
Udit-takkar a34a25e
chore: type error
Udit-takkar fe7ec30
fix: improvements and i18n
Udit-takkar 69f1ec1
chore: remove unused
Udit-takkar c153c8b
chore: more i18n
Udit-takkar b9c2de2
chore: i18n
Udit-takkar fcfb4f6
fix: type err
Udit-takkar 13ba93a
chore: missing check
Udit-takkar 990c9c8
chore: update docs
Udit-takkar 85878ec
nit
Udit-takkar 8d0d762
chore: i18n, other improvements
Udit-takkar 265e536
fix: formatting
Udit-takkar 70c18b8
fix: type
Udit-takkar 953001f
refactor: stripe related webhooks
Udit-takkar 49bd22a
Merge branch 'main' into feat/cal-ai-self-serve
emrysal 2cdbfa7
chore: imporovements
Udit-takkar 2f52003
fix: types
Udit-takkar 15ddc3d
chore
Udit-takkar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
apps/web/app/api/phone-numbers/subscription/success/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import type Stripe from "stripe"; | ||
| import { z } from "zod"; | ||
|
|
||
| import stripe from "@calcom/features/ee/payments/server/stripe"; | ||
| import { WEBAPP_URL } from "@calcom/lib/constants"; | ||
| import { HttpError } from "@calcom/lib/http-error"; | ||
|
|
||
| const querySchema = z.object({ | ||
| session_id: z.string().min(1), | ||
| }); | ||
|
|
||
| const checkoutSessionMetadataSchema = z.object({ | ||
| userId: z.string().transform(Number), | ||
| teamId: z | ||
| .string() | ||
| .optional() | ||
| .transform((val) => (val ? Number(val) : undefined)), | ||
| eventTypeId: z | ||
| .string() | ||
| .optional() | ||
| .transform((val) => (val ? Number(val) : undefined)), | ||
| agentId: z.string().optional(), | ||
| workflowId: z.string().optional(), | ||
| type: z.literal("phone_number_subscription"), | ||
| }); | ||
|
|
||
| type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>; | ||
|
|
||
| async function handler(request: NextRequest) { | ||
| try { | ||
| const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); | ||
| const checkoutSession = await getCheckoutSession(session_id); | ||
| const metadata = validateAndExtractMetadata(checkoutSession); | ||
|
|
||
| return redirectToSuccess(metadata); | ||
| } catch (error) { | ||
| return handleError(error); | ||
| } | ||
| } | ||
|
|
||
| async function getCheckoutSession(sessionId: string) { | ||
| const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] }); | ||
| if (!session) { | ||
| throw new HttpError({ statusCode: 404, message: "Checkout session not found" }); | ||
| } | ||
| return session; | ||
| } | ||
|
|
||
| function validateAndExtractMetadata(session: Stripe.Checkout.Session): CheckoutSessionMetadata { | ||
| if (session.payment_status !== "paid") { | ||
| throw new HttpError({ statusCode: 402, message: "Payment required" }); | ||
| } | ||
| if (!session.subscription) { | ||
| throw new HttpError({ statusCode: 400, message: "No subscription found in checkout session" }); | ||
| } | ||
|
|
||
| const result = checkoutSessionMetadataSchema.safeParse(session.metadata); | ||
| if (!result.success) { | ||
| throw new HttpError({ | ||
| statusCode: 400, | ||
| message: `Invalid checkout session metadata: ${result.error}`, | ||
| }); | ||
| } | ||
|
|
||
| return result.data; | ||
| } | ||
|
|
||
| function redirectToSuccess(metadata: CheckoutSessionMetadata) { | ||
| const basePath = metadata.workflowId | ||
| ? `${WEBAPP_URL}/workflows/${metadata.workflowId}` | ||
| : `${WEBAPP_URL}/workflows`; | ||
|
|
||
| return NextResponse.redirect(basePath); | ||
| } | ||
|
|
||
| function handleError(error: unknown) { | ||
| console.error("Error handling phone number subscription success:", error); | ||
|
|
||
| const url = new URL(`${WEBAPP_URL}/workflows`); | ||
| url.searchParams.set("error", "true"); | ||
|
|
||
| if (error instanceof HttpError) { | ||
| url.searchParams.set("message", error.message); | ||
| } else { | ||
| url.searchParams.set("message", "An error occurred while processing your subscription"); | ||
| } | ||
|
|
||
| return NextResponse.redirect(url.toString()); | ||
| } | ||
|
|
||
| export const GET = defaultResponderForAppDir(handler); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
| import { Retell } from "retell-sdk"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { CreditService } from "@calcom/features/ee/billing/credit-service"; | ||
| import { RETELL_API_KEY } from "@calcom/lib/constants"; | ||
| import logger from "@calcom/lib/logger"; | ||
| import { safeStringify } from "@calcom/lib/safeStringify"; | ||
| import { prisma } from "@calcom/prisma"; | ||
|
|
||
| const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] }); | ||
|
|
||
| const RetellWebhookSchema = z.object({ | ||
| event: z.enum(["call_started", "call_ended", "call_analyzed"]), | ||
| call: z | ||
| .object({ | ||
| call_id: z.string(), | ||
| agent_id: z.string().optional(), | ||
| from_number: z.string(), | ||
| to_number: z.string(), | ||
| direction: z.enum(["inbound", "outbound"]), | ||
| call_status: z.string(), | ||
| start_timestamp: z.number(), | ||
| end_timestamp: z.number().optional(), | ||
| disconnection_reason: z.string().optional(), | ||
| metadata: z.record(z.any()).optional(), | ||
| retell_llm_dynamic_variables: z.record(z.any()).optional(), | ||
| transcript: z.string().optional(), | ||
| opt_out_sensitive_data_storage: z.boolean().optional(), | ||
| call_cost: z | ||
| .object({ | ||
| product_costs: z | ||
| .array( | ||
| z.object({ | ||
| product: z.string(), | ||
| unitPrice: z.number().optional(), | ||
| cost: z.number().optional(), | ||
| }) | ||
| ) | ||
| .optional(), | ||
| total_duration_seconds: z.number().optional(), | ||
| total_duration_unit_price: z.number().optional(), | ||
| total_one_time_price: z.number().optional(), | ||
| combined_cost: z.number().optional(), | ||
| }) | ||
| .optional(), | ||
| call_analysis: z | ||
| .object({ | ||
| call_summary: z.string().optional(), | ||
| in_voicemail: z.boolean().optional(), | ||
| user_sentiment: z.string().optional(), | ||
| call_successful: z.boolean().optional(), | ||
| custom_analysis_data: z.record(z.any()).optional(), | ||
| }) | ||
| .optional(), | ||
| }) | ||
| .passthrough(), | ||
| }); | ||
|
|
||
| async function handleCallAnalyzed(callData: any) { | ||
| const { from_number, call_id, call_cost } = callData; | ||
| if (!call_cost || typeof call_cost.combined_cost !== "number") { | ||
| log.error(`No call_cost.combined_cost in payload for call ${call_id}`); | ||
| return; | ||
| } | ||
|
|
||
| const phoneNumber = await prisma.calAiPhoneNumber.findFirst({ | ||
| where: { phoneNumber: from_number }, | ||
| include: { | ||
| user: { select: { id: true, email: true, name: true } }, | ||
| team: { select: { id: true, name: true } }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!phoneNumber) { | ||
| log.error(`No phone number found for ${from_number}, cannot deduct credits`); | ||
| return; | ||
| } | ||
|
|
||
| // Support both personal and team phone numbers | ||
| const userId = phoneNumber.userId; | ||
| const teamId = phoneNumber.teamId; | ||
|
|
||
| if (!userId && !teamId) { | ||
| log.error(`Phone number ${from_number} has no associated user or team`); | ||
| return; | ||
| } | ||
|
|
||
| const baseCost = call_cost.combined_cost; // in cents | ||
| const creditsToDeduct = Math.ceil(baseCost * 1.8); | ||
|
|
||
| const creditService = new CreditService(); | ||
| const hasCredits = await creditService.hasAvailableCredits({ userId, teamId }); | ||
| if (!hasCredits) { | ||
| log.error( | ||
| `${ | ||
| teamId ? `Team ${teamId}` : `User ${userId}` | ||
| } has insufficient credits for call ${call_id} (${creditsToDeduct} credits needed)` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| await creditService.chargeCredits({ | ||
| userId: userId ?? undefined, | ||
| teamId: teamId ?? undefined, | ||
| credits: creditsToDeduct, | ||
| }); | ||
|
|
||
| return { | ||
| success: true, | ||
| message: `Successfully charged ${creditsToDeduct} credits for ${ | ||
| teamId ? `team ${teamId}` : `user ${userId}` | ||
| }, call ${call_id} (base cost: ${baseCost} cents)`, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Retell AI Webhook Handler | ||
| * | ||
| * Setup Instructions: | ||
| * 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai | ||
| * 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar) | ||
| * 3. Set the RETELL_API_KEY environment variable with your Retell API key (must have webhook badge) | ||
| * | ||
| * This webhook will: | ||
| * - Verify webhook signature for security | ||
| * - Receive call_analyzed events from Retell AI | ||
| * - Charge credits based on the call cost from the user's or team's credit balance | ||
| * - Log all transactions for audit purposes | ||
| */ | ||
| async function handler(request: NextRequest) { | ||
| // Get the raw body for signature verification | ||
| const rawBody = await request.text(); | ||
| const body = JSON.parse(rawBody); | ||
|
|
||
| // Verify webhook signature | ||
| const signature = request.headers.get("x-retell-signature"); | ||
| const apiKey = RETELL_API_KEY; | ||
|
|
||
| if (!signature || !apiKey) { | ||
| log.error("Missing signature or API key for webhook verification"); | ||
| return NextResponse.json( | ||
| { | ||
| error: "Unauthorized", | ||
| message: "Missing signature or API key", | ||
| }, | ||
| { status: 401 } | ||
| ); | ||
| } | ||
|
|
||
| if (!Retell.verify(rawBody, apiKey, signature)) { | ||
| log.error("Invalid webhook signature"); | ||
| return NextResponse.json( | ||
| { | ||
| error: "Unauthorized", | ||
| message: "Invalid signature", | ||
| }, | ||
| { status: 401 } | ||
| ); | ||
| } | ||
|
|
||
| if (body.event !== "call_analyzed") { | ||
| return NextResponse.json({ | ||
| success: true, | ||
| message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`, | ||
| }); | ||
| } | ||
|
|
||
| try { | ||
| const payload = RetellWebhookSchema.parse(body); | ||
| const callData = payload.call; | ||
| log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`); | ||
|
|
||
| const result = await handleCallAnalyzed(callData); | ||
|
|
||
| return NextResponse.json({ | ||
| success: true, | ||
| message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`, | ||
| }); | ||
| } catch (error) { | ||
| log.error("Error processing Retell AI webhook:", safeStringify(error)); | ||
| return NextResponse.json( | ||
| { | ||
| error: "Internal server error", | ||
| message: error instanceof Error ? error.message : "Unknown error", | ||
| }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
Udit-takkar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export const POST = defaultResponderForAppDir(handler); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -138,6 +138,7 @@ | |
| "react-use-intercom": "1.5.1", | ||
| "recoil": "^0.7.7", | ||
| "remove-markdown": "^0.5.0", | ||
| "retell-sdk": "^4.40.0", | ||
|
Contributor
Author
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. Need this for calling retell ai API and validating request to webhook endpoint after the call to deduct credits |
||
| "rrule": "^2.7.1", | ||
| "sanitize-html": "^2.10.0", | ||
| "schema-dts": "^1.1.0", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; | ||
| import { aiRouter } from "@calcom/trpc/server/routers/viewer/ai/_router"; | ||
|
|
||
| export default createNextApiHandler(aiRouter); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; | ||
| import { phoneNumberRouter } from "@calcom/trpc/server/routers/viewer/phoneNumber/_router"; | ||
|
|
||
| export default createNextApiHandler(phoneNumberRouter); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.