Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,7 @@ LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id)

# DOS Ecosystem Webhook Sync Secret
DOS_SYNC_WEBHOOK_SECRET=

# Crove CRM Integration (crm.crove.com)
CROVE_CRM_API_KEY=
CROVE_CRM_API_URL="https://crm.crove.com/api/v1"
72 changes: 72 additions & 0 deletions apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "../route";

const mockMethods = {
isConfigured: vi.fn(),
syncBookingEvent: vi.fn(),
};

vi.mock("@calcom/features/crove-crm", () => {
return {
CroveCrmService: class MockCroveCrmService {
isConfigured() {
return mockMethods.isConfigured();
}
syncBookingEvent(args: any) {
return mockMethods.syncBookingEvent(args);
}
},
};
});

describe("POST /api/webhooks/crove-crm", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should return 500 if Crove CRM is not configured", async () => {
mockMethods.isConfigured.mockReturnValue(false);

const req = new NextRequest("http://localhost:3000/api/webhooks/crove-crm", {
method: "POST",
body: JSON.stringify({
triggerEvent: "BOOKING_CREATED",
payload: { uid: "123" },
}),
});

const res = await POST(req);
expect(res.status).toBe(500);
const json = await res.json();
expect(json.error).toContain("CROVE_CRM_API_KEY");
});

it("should sync booking event and return 200 OK when configured", async () => {
mockMethods.isConfigured.mockReturnValue(true);
mockMethods.syncBookingEvent.mockResolvedValue({
success: true,
syncedContacts: 1,
results: [{ success: true, contactId: "c_1", activityId: "a_1" }],
});

const req = new NextRequest("http://localhost:3000/api/webhooks/crove-crm", {
method: "POST",
body: JSON.stringify({
triggerEvent: "BOOKING_CREATED",
payload: {
uid: "booking_123",
eventTitle: "Consultation 30m",
attendees: [{ email: "client@example.com", name: "Client" }],
},
}),
});

const res = await POST(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.success).toBe(true);
expect(json.syncedContacts).toBe(1);
expect(mockMethods.syncBookingEvent).toHaveBeenCalled();
});
});
93 changes: 93 additions & 0 deletions apps/web/app/api/webhooks/crove-crm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { CroveCrmService } from "@calcom/features/crove-crm";
import { webhookMonitor } from "@calcom/lib/webhookMonitor";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-cal-signature-256",
};

export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
});
}

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.

const startTime = Date.now();
let triggerEventName = "unknown";

try {
const rawBody = await req.text();
if (!rawBody) {
webhookMonitor.recordDelivery({
source: "crove-crm",
event: "error.empty_body",
status: 400,
latencyMs: Date.now() - startTime,
success: false,
error: "Empty request body",
});
return NextResponse.json({ error: "Empty request body" }, { status: 400, headers: corsHeaders });
}

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

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;


const crm = new CroveCrmService();
if (!crm.isConfigured()) {
webhookMonitor.recordDelivery({
source: "crove-crm",
event: triggerEventName,
status: 500,
latencyMs: Date.now() - startTime,
success: false,
error: "Crove CRM API key is not configured",
});
return NextResponse.json(
{ error: "Crove CRM API key is not configured in CROVE_CRM_API_KEY" },
{ status: 500, headers: corsHeaders }
);
}

const syncResult = await crm.syncBookingEvent({
triggerEvent,
payload: eventData,
});

webhookMonitor.recordDelivery({
source: "crove-crm",
event: triggerEventName,
status: 200,
latencyMs: Date.now() - startTime,
success: syncResult.success,
summary: `Synced ${syncResult.syncedContacts} contact(s) and activities into Crove CRM`,
});

return NextResponse.json(
{
success: syncResult.success,
triggerEvent,
syncedContacts: syncResult.syncedContacts,
results: syncResult.results,
},
{ status: 200, headers: corsHeaders }
);
} catch (error) {
const message = error instanceof Error ? error.message : "Internal Server Error";
webhookMonitor.recordDelivery({
source: "crove-crm",
event: triggerEventName,
status: 500,
latencyMs: Date.now() - startTime,
success: false,
error: message,
});
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
}
}
128 changes: 128 additions & 0 deletions packages/features/crove-crm/__tests__/croveCrmService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CroveCrmService } from "../croveCrmService";

describe("CroveCrmService", () => {
const TEST_API_KEY = "crm_live_test_123456789";

beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});

it("should report isConfigured correctly based on API key", () => {
const unconfigured = new CroveCrmService("");
expect(unconfigured.isConfigured()).toBe(false);

const configured = new CroveCrmService(TEST_API_KEY);
expect(configured.isConfigured()).toBe(true);
});

it("upsertContact should post structured contact payload with team & org mapping to Crove CRM", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: "contact_123" }),
});
global.fetch = mockFetch;

const crm = new CroveCrmService(TEST_API_KEY);
const result = await crm.upsertContact({
email: "lead@example.com",
name: "David Nguyen",
phone: "+84901234567",
timeZone: "Asia/Ho_Chi_Minh",
organizationId: "org_987654321",
teamId: "team_11223344",
});

expect(result.success).toBe(true);
expect(result.contactId).toBe("contact_123");
expect(mockFetch).toHaveBeenCalledWith(
"https://crm.crove.com/api/v1/contacts",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: `Bearer ${TEST_API_KEY}`,
"Content-Type": "application/json",
}),
body: JSON.stringify({
email: "lead@example.com",
name: "David Nguyen",
first_name: "David",
last_name: "Nguyen",
phone: "+84901234567",
timezone: "Asia/Ho_Chi_Minh",
organization_id: "org_987654321",
team_id: "team_11223344",
source: "crove-cal",
tags: ["cal-booking", "source:crove-cal"],
custom_fields: {},
}),
})
);
});

it("recordBookingActivity should post timeline activity payload to Crove CRM", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: "act_456" }),
});
global.fetch = mockFetch;

const crm = new CroveCrmService(TEST_API_KEY);
const result = await crm.recordBookingActivity({
contactEmail: "lead@example.com",
activityType: "meeting_scheduled",
bookingUid: "bk_789",
title: "Discovery Call 30m",
startTime: "2026-09-03T10:00:00Z",
organizerEmail: "host@crove.com",
organizationId: "org_987654321",
teamId: "team_11223344",
});

expect(result.success).toBe(true);
expect(result.activityId).toBe("act_456");
expect(mockFetch).toHaveBeenCalledWith(
"https://crm.crove.com/api/v1/activities",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: `Bearer ${TEST_API_KEY}`,
}),
})
);
});

it("syncBookingEvent should handle full booking lifecycle and multiple attendees", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: "synced_id" }),
});
global.fetch = mockFetch;

const crm = new CroveCrmService(TEST_API_KEY);
const result = await crm.syncBookingEvent({
triggerEvent: "BOOKING_CREATED",
payload: {
uid: "booking_abc",
title: "Product Demo",
startTime: "2026-09-03T14:00:00Z",
organizer: { email: "sales@crove.com", name: "Sales Rep" },
attendees: [
{ email: "client1@acme.com", name: "Client One" },
{ email: "client2@acme.com", name: "Client Two" },
],
organizationId: "org_1",
teamId: "team_sales",
},
});

expect(result.success).toBe(true);
expect(result.syncedContacts).toBe(2);
// 2 attendees x (1 contact upsert + 1 activity record) = 4 API calls
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});
Loading
Loading