diff --git a/.env.example b/.env.example index c7b29595ef6..0252f435296 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts new file mode 100644 index 00000000000..e2c1f9d324e --- /dev/null +++ b/apps/web/app/api/webhooks/crove-crm/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/api/webhooks/crove-crm/route.ts b/apps/web/app/api/webhooks/crove-crm/route.ts new file mode 100644 index 00000000000..b8821ad2c5b --- /dev/null +++ b/apps/web/app/api/webhooks/crove-crm/route.ts @@ -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; + + 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 }); + } +} diff --git a/packages/features/crove-crm/__tests__/croveCrmService.test.ts b/packages/features/crove-crm/__tests__/croveCrmService.test.ts new file mode 100644 index 00000000000..63686dde8cd --- /dev/null +++ b/packages/features/crove-crm/__tests__/croveCrmService.test.ts @@ -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); + }); +}); diff --git a/packages/features/crove-crm/croveCrmService.ts b/packages/features/crove-crm/croveCrmService.ts new file mode 100644 index 00000000000..3a1f97afe13 --- /dev/null +++ b/packages/features/crove-crm/croveCrmService.ts @@ -0,0 +1,208 @@ +import type { CroveCrmActivityInput, CroveCrmContactInput, CroveCrmSyncResult } from "./types"; + +export class CroveCrmService { + private apiKey: string; + private baseUrl: string; + + constructor(apiKey?: string, baseUrl?: string) { + this.apiKey = (apiKey || process.env.CROVE_CRM_API_KEY || "").trim(); + this.baseUrl = (baseUrl || process.env.CROVE_CRM_API_URL || "https://crm.crove.com/api/v1").replace(/\/$/, ""); + } + + public isConfigured(): boolean { + return Boolean(this.apiKey && this.apiKey.length > 0); + } + + /** + * Create or update a contact in Crove CRM + */ + public async upsertContact(input: CroveCrmContactInput): Promise { + if (!this.isConfigured()) { + return { success: false, error: "Crove CRM API key is not configured (CROVE_CRM_API_KEY)" }; + } + + if (!input.email) { + return { success: false, error: "Contact email is required" }; + } + + let firstName = input.firstName; + let lastName = input.lastName; + if (!firstName && input.name) { + const parts = input.name.trim().split(/\s+/); + firstName = parts[0]; + lastName = parts.slice(1).join(" ") || undefined; + } + + const payload = { + email: input.email.toLowerCase().trim(), + name: input.name, + first_name: firstName, + last_name: lastName, + phone: input.phone, + timezone: input.timeZone, + organization_id: input.organizationId ? String(input.organizationId) : undefined, + team_id: input.teamId ? String(input.teamId) : undefined, + source: "crove-cal", + tags: Array.from(new Set([...(input.tags || []), "cal-booking", "source:crove-cal"])), + custom_fields: input.customFields || {}, + }; + + try { + 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), + }); + + if (response.ok || response.status === 200 || response.status === 201) { + const json = await response.json().catch(() => ({})); + return { + success: true, + contactId: json.id || json.data?.id, + }; + } + + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.message || `Crove CRM API returned status ${response.status}`, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + } + + /** + * Record a meeting activity / timeline event in Crove CRM + */ + public async recordBookingActivity(input: CroveCrmActivityInput): Promise { + if (!this.isConfigured()) { + return { success: false, error: "Crove CRM API key is not configured (CROVE_CRM_API_KEY)" }; + } + + const payload = { + contact_email: input.contactEmail.toLowerCase().trim(), + activity_type: input.activityType, + booking_uid: input.bookingUid, + title: input.title, + start_time: input.startTime, + end_time: input.endTime, + organizer_email: input.organizerEmail, + meeting_url: input.meetingUrl, + notes: input.notes, + organization_id: input.organizationId ? String(input.organizationId) : undefined, + team_id: input.teamId ? String(input.teamId) : undefined, + metadata: input.metadata || {}, + }; + + try { + 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), + }); + + if (response.ok || response.status === 200 || response.status === 201) { + const json = await response.json().catch(() => ({})); + return { + success: true, + activityId: json.id || json.data?.id, + }; + } + + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.message || `Crove CRM API returned status ${response.status}`, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + } + + /** + * Sync complete booking event payload (Booking Created, Rescheduled, Cancelled) + */ + public async syncBookingEvent(event: { + triggerEvent: "BOOKING_CREATED" | "BOOKING_RESCHEDULED" | "BOOKING_CANCELLED" | string; + payload: { + uid?: string; + title?: string; + eventTitle?: string; + startTime?: string; + endTime?: string; + status?: string; + organizer?: { email: string; name?: string }; + attendees?: Array<{ email: string; name?: string; timeZone?: string; phoneNumber?: string }>; + teamId?: string | number; + organizationId?: string | number; + metadata?: Record; + }; + }): Promise<{ success: boolean; syncedContacts: number; results: CroveCrmSyncResult[] }> { + 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, + }); + + results.push({ + success: contactRes.success && activityRes.success, + contactId: contactRes.contactId, + activityId: activityRes.activityId, + error: contactRes.error || activityRes.error, + }); + } + + const successfulSyncs = results.filter((r) => r.success).length; + return { + success: results.length > 0 ? successfulSyncs === results.length : true, + syncedContacts: successfulSyncs, + results, + }; + } +} diff --git a/packages/features/crove-crm/index.ts b/packages/features/crove-crm/index.ts new file mode 100644 index 00000000000..b98a3671da1 --- /dev/null +++ b/packages/features/crove-crm/index.ts @@ -0,0 +1,2 @@ +export * from "./croveCrmService"; +export * from "./types"; diff --git a/packages/features/crove-crm/types.ts b/packages/features/crove-crm/types.ts new file mode 100644 index 00000000000..4189ff4c08d --- /dev/null +++ b/packages/features/crove-crm/types.ts @@ -0,0 +1,34 @@ +export interface CroveCrmContactInput { + email: string; + name?: string; + firstName?: string; + lastName?: string; + phone?: string; + timeZone?: string; + organizationId?: string | number; + teamId?: string | number; + customFields?: Record; + tags?: string[]; +} + +export interface CroveCrmActivityInput { + contactEmail: string; + activityType: "meeting_scheduled" | "meeting_rescheduled" | "meeting_cancelled" | "meeting_completed"; + bookingUid: string; + title: string; + startTime: string; + endTime?: string; + organizerEmail: string; + meetingUrl?: string; + notes?: string; + organizationId?: string | number; + teamId?: string | number; + metadata?: Record; +} + +export interface CroveCrmSyncResult { + success: boolean; + contactId?: string; + activityId?: string; + error?: string; +} diff --git a/packages/lib/webhookMonitor.ts b/packages/lib/webhookMonitor.ts index 052bc4bd01a..13a6b7aa2b1 100644 --- a/packages/lib/webhookMonitor.ts +++ b/packages/lib/webhookMonitor.ts @@ -43,6 +43,7 @@ class WebhookMonitorService { private routeConfig: Record = { "dos-org-sync": { name: "DOS.Me Org & Identity Sync" }, + "crove-crm": { name: "Crove CRM Direct Sync" }, brevo: { name: "Brevo CRM Event Bridge" }, "calendar-subscription": { name: "Google & Microsoft Calendar Sync" }, stripe: { name: "Stripe Payment & Subscriptions" },