Skip to content

feat: implement Crove CRM direct sync module & webhook bridge - #62

Merged
JOY (JOY) merged 1 commit into
mainfrom
dev
Sep 3, 2026
Merged

JOY (JOY) merged 1 commit into
mainfrom
dev

Conversation

@JOY

@JOY JOY (JOY) commented Sep 3, 2026

Copy link
Copy Markdown

Summary

  • Crove CRM Direct Integration: Added CroveCrmService (@calcom/features/crove-crm) for direct contact upsert, activity logging, and booking sync with crm.crove.com.
  • Crove CRM Webhook Bridge: Added /api/webhooks/crove-crm endpoint and registered with WebhookMonitor telemetry.
  • Unit & Integration Tests: Verified Crove CRM service and webhook endpoint with 100% test pass.
  • All 114 monorepo packages passed urbo type-check.

Test plan

  • Yarn turbo type-check: 114/114 packages passed
  • Vitest tests: 8/8 test files, 46/46 tests passed

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-crm package exposes CroveCrmService with upsertContact, recordBookingActivity, and syncBookingEvent (maps BOOKING_CREATED / RESCHEDULED / CANCELLED to activity types and processes each attendee with org/team IDs and crove-cal tagging). Configuration is via CROVE_CRM_API_KEY and optional CROVE_CRM_API_URL in .env.example.

POST /api/webhooks/crove-crm accepts flexible JSON (triggerEvent / payload), returns 400 on empty body and 500 when the API key is missing, and records deliveries under crove-crm in webhookMonitor. Vitest covers the service and route.

Reviewed by Cursor Bugbot for commit 968b376. Configure here.

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.
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 3fdf2796-042e-457a-b829-3a6179841ec4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Sep 3, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@JOY
JOY (JOY) merged commit cc738e4 into main Sep 3, 2026
23 of 25 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

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.

Comment on lines +37 to +40
const payload = JSON.parse(rawBody);
const triggerEvent = payload.triggerEvent || payload.event || "BOOKING_CREATED";
triggerEventName = triggerEvent;
const eventData = payload.payload || payload.data || payload;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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;

Comment on lines +51 to +59
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),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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),
});

Comment on lines +104 to +112
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),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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),
});

Comment on lines +152 to +191
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,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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,
      });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant