Conversation
Add CroveCrmService with contact upsert, activity logging, and booking sync. Add /api/webhooks/crove-crm webhook endpoint and register with WebhookMonitor. Update .env.example with CROVE_CRM_API_KEY.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_5c99435f-bd40-4a5a-8736-8ed686bf1369) |
There was a problem hiding this comment.
Code Review
This pull request introduces a Crove CRM integration, adding a new webhook endpoint to handle booking events and a dedicated service to sync contacts and record timeline activities. The review feedback highlights critical security and robustness improvements, including implementing signature verification for the webhook endpoint, handling JSON parsing errors gracefully to avoid 500 status codes, adding timeouts to API fetch requests, and defensively handling potentially undefined payload objects.
| }); | ||
| } | ||
|
|
||
| export async function POST(req: NextRequest) { |
There was a problem hiding this comment.
The webhook endpoint allows unauthenticated POST requests. Although x-cal-signature-256 is listed in the CORS headers, there is no logic in the POST handler to verify the signature or validate a shared secret. This allows anyone to trigger contact upserts and activity logs in Crove CRM, which could lead to spam or abuse of your CRM API limits.
Please implement signature verification using a webhook secret (e.g., verifying the x-cal-signature-256 header) before processing the payload.
| const payload = JSON.parse(rawBody); | ||
| const triggerEvent = payload.triggerEvent || payload.event || "BOOKING_CREATED"; | ||
| triggerEventName = triggerEvent; | ||
| const eventData = payload.payload || payload.data || payload; |
There was a problem hiding this comment.
If the request body contains invalid JSON or is not an object, JSON.parse(rawBody) will throw an unhandled exception that gets caught by the outer catch block, returning a 500 Internal Server Error.
Parsing errors should be handled specifically to return a 400 Bad Request instead, preventing invalid client payloads from polluting server error logs and monitoring metrics.
let payload;
try {
payload = JSON.parse(rawBody);
} catch (error) {
webhookMonitor.recordDelivery({
source: "crove-crm",
event: "error.invalid_json",
status: 400,
latencyMs: Date.now() - startTime,
success: false,
error: "Invalid JSON payload",
});
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400, headers: corsHeaders });
}
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Invalid payload format" }, { status: 400, headers: corsHeaders });
}
const triggerEvent = payload.triggerEvent || payload.event || "BOOKING_CREATED";
triggerEventName = triggerEvent;
const eventData = payload.payload || payload.data || payload;| const response = await fetch(`${this.baseUrl}/contacts`, { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${this.apiKey}`, | ||
| "Content-Type": "application/json", | ||
| accept: "application/json", | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| }); |
There was a problem hiding this comment.
The fetch call does not specify a timeout. If the Crove CRM API is unresponsive or slow, the request can hang indefinitely, which can exhaust serverless function execution limits or block event loop resources.
Adding a timeout (e.g., using AbortSignal.timeout) ensures the request fails fast and can be retried or logged appropriately.
| const response = await fetch(`${this.baseUrl}/contacts`, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${this.apiKey}`, | |
| "Content-Type": "application/json", | |
| accept: "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const response = await fetch(this.baseUrl + "/contacts", { | |
| method: "POST", | |
| headers: { | |
| Authorization: "Bearer " + this.apiKey, | |
| "Content-Type": "application/json", | |
| accept: "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| signal: AbortSignal.timeout(10000), | |
| }); |
| const response = await fetch(`${this.baseUrl}/activities`, { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${this.apiKey}`, | ||
| "Content-Type": "application/json", | ||
| accept: "application/json", | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| }); |
There was a problem hiding this comment.
The fetch call does not specify a timeout. If the Crove CRM API is unresponsive or slow, the request can hang indefinitely. Adding a timeout ensures the request fails fast.
| const response = await fetch(`${this.baseUrl}/activities`, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${this.apiKey}`, | |
| "Content-Type": "application/json", | |
| accept: "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const response = await fetch(this.baseUrl + "/activities", { | |
| method: "POST", | |
| headers: { | |
| Authorization: "Bearer " + this.apiKey, | |
| "Content-Type": "application/json", | |
| accept: "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| signal: AbortSignal.timeout(10000), | |
| }); |
| const attendees = event.payload.attendees || []; | ||
| const meetingTitle = event.payload.eventTitle || event.payload.title || "Meeting"; | ||
| const bookingUid = event.payload.uid || `booking_${Date.now()}`; | ||
| const organizerEmail = event.payload.organizer?.email || "organizer@crove.com"; | ||
|
|
||
| let activityType: CroveCrmActivityInput["activityType"] = "meeting_scheduled"; | ||
| if (event.triggerEvent === "BOOKING_RESCHEDULED") { | ||
| activityType = "meeting_rescheduled"; | ||
| } else if (event.triggerEvent === "BOOKING_CANCELLED") { | ||
| activityType = "meeting_cancelled"; | ||
| } | ||
|
|
||
| const results: CroveCrmSyncResult[] = []; | ||
|
|
||
| for (const attendee of attendees) { | ||
| if (!attendee.email) continue; | ||
|
|
||
| // 1. Upsert contact | ||
| const contactRes = await this.upsertContact({ | ||
| email: attendee.email, | ||
| name: attendee.name, | ||
| phone: attendee.phoneNumber, | ||
| timeZone: attendee.timeZone, | ||
| organizationId: event.payload.organizationId, | ||
| teamId: event.payload.teamId, | ||
| }); | ||
|
|
||
| // 2. Record activity | ||
| const activityRes = await this.recordBookingActivity({ | ||
| contactEmail: attendee.email, | ||
| activityType, | ||
| bookingUid, | ||
| title: meetingTitle, | ||
| startTime: event.payload.startTime || new Date().toISOString(), | ||
| endTime: event.payload.endTime, | ||
| organizerEmail, | ||
| organizationId: event.payload.organizationId, | ||
| teamId: event.payload.teamId, | ||
| metadata: event.payload.metadata, | ||
| }); |
There was a problem hiding this comment.
To adhere to defensive programming practices, we should safely handle cases where event.payload might be undefined or null. Extracting payload with a fallback to an empty object and using it consistently throughout the function prevents potential TypeError crashes when accessing properties.
const payload = event.payload || {};
const attendees = payload.attendees || [];
const meetingTitle = payload.eventTitle || payload.title || "Meeting";
const bookingUid = payload.uid || "booking_" + Date.now();
const organizerEmail = payload.organizer?.email || "organizer@crove.com";
let activityType: CroveCrmActivityInput["activityType"] = "meeting_scheduled";
if (event.triggerEvent === "BOOKING_RESCHEDULED") {
activityType = "meeting_rescheduled";
} else if (event.triggerEvent === "BOOKING_CANCELLED") {
activityType = "meeting_cancelled";
}
const results: CroveCrmSyncResult[] = [];
for (const attendee of attendees) {
if (!attendee.email) continue;
// 1. Upsert contact
const contactRes = await this.upsertContact({
email: attendee.email,
name: attendee.name,
phone: attendee.phoneNumber,
timeZone: attendee.timeZone,
organizationId: payload.organizationId,
teamId: payload.teamId,
});
// 2. Record activity
const activityRes = await this.recordBookingActivity({
contactEmail: attendee.email,
activityType,
bookingUid,
title: meetingTitle,
startTime: payload.startTime || new Date().toISOString(),
endTime: payload.endTime,
organizerEmail,
organizationId: payload.organizationId,
teamId: payload.teamId,
metadata: payload.metadata,
});
Summary
Test plan
Note
Medium Risk
The endpoint syncs attendee PII to an external CRM via a bearer token and does not appear to verify webhook signatures (unlike some sibling routes), so misconfiguration or exposure could allow unauthorized sync attempts.
Overview
Adds Crove CRM integration so Cal booking events can upsert contacts and log meeting activities on
crm.crove.com.New
@calcom/features/crove-crmpackage exposesCroveCrmServicewithupsertContact,recordBookingActivity, andsyncBookingEvent(mapsBOOKING_CREATED/RESCHEDULED/CANCELLEDto activity types and processes each attendee with org/team IDs andcrove-caltagging). Configuration is viaCROVE_CRM_API_KEYand optionalCROVE_CRM_API_URLin.env.example.POST /api/webhooks/crove-crmaccepts flexible JSON (triggerEvent/payload), returns 400 on empty body and 500 when the API key is missing, and records deliveries undercrove-crminwebhookMonitor. Vitest covers the service and route.Reviewed by Cursor Bugbot for commit 968b376. Configure here.