-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement Crove CRM direct sync module & webhook bridge #62
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); |
| 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) { | ||
| 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
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. 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 }); | ||
| } | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.