Google Calendar API Allowing Users to Book Meetings Directly Into Your CRM

Google Calendar API: Allowing Users to Book Meetings Directly Into Your CRM

The Problem

Prospect meetings get lost between systems. Your sales team uses a CRM to track leads, but scheduling happens in Google Calendar. When a prospect books a meeting, reps manually create a CRM activity record, copy meeting details, attach the calendar link, and hope they remembered to set a reminder. This double-entry wastes time and creates data inconsistencies—meetings scheduled but not logged in the CRM, wrong contact associations, missing follow-up tasks. Worse, prospects can’t self-book meetings that automatically sync to your CRM, forcing them to email back-and-forth for availability. You need bidirectional sync: when a prospect books a meeting via your booking page, it creates both a Google Calendar event and a CRM activity record with proper contact linking. When the meeting is updated or canceled, both systems reflect the change. However, Google Calendar API requires OAuth 2.0 flows, webhook verification, event parsing with timezone handling, and conflict detection. This tutorial provides production-ready code for a meeting booking system that syncs between Google Calendar and your CRM with proper authentication and real-time updates.

Tech Stack & Prerequisites

  • Node.js v18+ with npm
  • Express.js 4.18+ for backend server
  • Google Cloud Project with Calendar API enabled
  • Google OAuth 2.0 Credentials (Client ID, Client Secret)
  • googleapis 129.0+ (Google API Node.js client)
  • PostgreSQL 14+ or MongoDB for storing calendar-CRM mappings
  • dotenv for environment variables
  • React 18+ for booking interface (frontend)
  • date-fns 3.0+ or moment-timezone for date handling
  • pg 8.11+ for PostgreSQL connection

Required Google Cloud Setup:

  • Google Cloud Console project created
  • Calendar API enabled in API Library
  • OAuth 2.0 Client ID created (Web application type)
  • Authorized redirect URIs configured
  • OAuth consent screen configured

Required CRM Setup:

  • CRM with API access (HubSpot, Salesforce, or Pipedrive)
  • API credentials for creating activities/meetings
  • Contact/Lead IDs for association

Step-by-Step Implementation

Step 1: Setup

Initialize the project:

bash
mkdir calendar-crm-integration
cd calendar-crm-integration

# Backend setup
mkdir backend
cd backend
npm init -y
npm install express googleapis pg dotenv cors body-parser date-fns
npm install --save-dev nodemon

# Frontend setup
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios date-fns react-calendar
cd ..

Create backend structure:

bash
cd backend
mkdir src api utils db
touch src/server.js api/googleCalendar.js api/crm.js utils/dateHelpers.js
touch db/database.js db/schema.sql .env .gitignore
```

Your structure should be:
```
calendar-crm-integration/
├── backend/
│   ├── src/
│   │   └── server.js
│   ├── api/
│   │   ├── googleCalendar.js
│   │   └── crm.js
│   ├── utils/
│   │   └── dateHelpers.js
│   ├── db/
│   │   ├── database.js
│   │   └── schema.sql
│   ├── .env
│   ├── .gitignore
│   └── package.json
└── frontend/
    ├── src/
    │   ├── App.jsx
    │   └── components/
    │       └── BookingForm.jsx
    └── package.json

backend/db/schema.sql — Database schema for mappings:

sql
-- Store OAuth tokens for users
CREATE TABLE IF NOT EXISTS user_tokens (
    id SERIAL PRIMARY KEY,
    user_id INTEGER UNIQUE NOT NULL,
    email VARCHAR(255) NOT NULL,
    access_token TEXT NOT NULL,
    refresh_token TEXT,
    token_expiry TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Store calendar-CRM event mappings
CREATE TABLE IF NOT EXISTS calendar_events (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES user_tokens(user_id),
    google_event_id VARCHAR(255) UNIQUE NOT NULL,
    crm_activity_id VARCHAR(255),
    crm_type VARCHAR(50), -- 'hubspot', 'salesforce', 'pipedrive'
    contact_id VARCHAR(255),
    contact_email VARCHAR(255),
    event_title TEXT,
    event_start TIMESTAMP NOT NULL,
    event_end TIMESTAMP NOT NULL,
    event_status VARCHAR(50), -- 'confirmed', 'cancelled', 'tentative'
    meeting_link TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Index for faster lookups
CREATE INDEX idx_google_event_id ON calendar_events(google_event_id);
CREATE INDEX idx_crm_activity_id ON calendar_events(crm_activity_id);
CREATE INDEX idx_user_id ON calendar_events(user_id);
CREATE INDEX idx_event_start ON calendar_events(event_start);

-- Store webhook channel information
CREATE TABLE IF NOT EXISTS webhook_channels (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES user_tokens(user_id),
    channel_id VARCHAR(255) UNIQUE NOT NULL,
    resource_id VARCHAR(255) NOT NULL,
    expiration TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Run schema:

bash
psql -U your_username -d your_database -f db/schema.sql

backend/package.json — Add scripts:

json
{
  "name": "calendar-crm-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "body-parser": "^1.20.2",
    "cors": "^2.8.5",
    "date-fns": "^3.0.0",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "googleapis": "^129.0.0",
    "pg": "^8.11.3"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

backend/.gitignore:

bash
echo "node_modules/
.env
*.log
credentials.json" > .gitignore

Step 2: Configuration

backend/.env — Store credentials securely:

env
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
GOOGLE_REDIRECT_URI=http://localhost:5000/oauth/callback
GOOGLE_CALENDAR_WEBHOOK_URL=https://your-domain.com/webhooks/google-calendar

# CRM Configuration (HubSpot example)
HUBSPOT_API_KEY=your_hubspot_api_key
CRM_TYPE=hubspot

# PostgreSQL Configuration
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=calendar_crm
PG_USER=your_username
PG_PASSWORD=your_password

# Server Configuration
PORT=5000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173

How to get Google OAuth credentials:

  1. Go to https://console.cloud.google.com
  2. Create project or select existing
  3. Enable Google Calendar API in API Library
  4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID
  5. Application type: Web application
  6. Authorized redirect URIs: http://localhost:5000/oauth/callback
  7. Copy Client ID and Client Secret

backend/db/database.js — Database connection and operations:

javascript
import pg from 'pg';
import dotenv from 'dotenv';

dotenv.config();

const { Pool } = pg;

const pool = new Pool({
  host: process.env.PG_HOST,
  port: process.env.PG_PORT,
  database: process.env.PG_DATABASE,
  user: process.env.PG_USER,
  password: process.env.PG_PASSWORD,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Test connection
export async function testConnection() {
  try {
    const client = await pool.connect();
    console.log('✓ PostgreSQL connected');
    client.release();
    return true;
  } catch (error) {
    console.error('✗ PostgreSQL connection failed:', error.message);
    return false;
  }
}

// Store user OAuth tokens
export async function saveUserTokens(userId, email, tokens) {
  const query = `
    INSERT INTO user_tokens (user_id, email, access_token, refresh_token, token_expiry)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (user_id)
    DO UPDATE SET
      access_token = EXCLUDED.access_token,
      refresh_token = EXCLUDED.refresh_token,
      token_expiry = EXCLUDED.token_expiry,
      updated_at = CURRENT_TIMESTAMP
    RETURNING *
  `;

  const expiry = new Date(Date.now() + (tokens.expiry_date || 3600 * 1000));

  const result = await pool.query(query, [
    userId,
    email,
    tokens.access_token,
    tokens.refresh_token,
    expiry,
  ]);

  return result.rows[0];
}

// Get user tokens
export async function getUserTokens(userId) {
  const query = 'SELECT * FROM user_tokens WHERE user_id = $1';
  const result = await pool.query(query, [userId]);
  return result.rows[0];
}

// Store calendar-CRM event mapping
export async function saveCalendarEvent(eventData) {
  const query = `
    INSERT INTO calendar_events (
      user_id, google_event_id, crm_activity_id, crm_type,
      contact_id, contact_email, event_title, event_start,
      event_end, event_status, meeting_link
    ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
    ON CONFLICT (google_event_id)
    DO UPDATE SET
      crm_activity_id = EXCLUDED.crm_activity_id,
      event_status = EXCLUDED.event_status,
      event_start = EXCLUDED.event_start,
      event_end = EXCLUDED.event_end,
      updated_at = CURRENT_TIMESTAMP
    RETURNING *
  `;

  const values = [
    eventData.userId,
    eventData.googleEventId,
    eventData.crmActivityId,
    eventData.crmType,
    eventData.contactId,
    eventData.contactEmail,
    eventData.eventTitle,
    eventData.eventStart,
    eventData.eventEnd,
    eventData.eventStatus,
    eventData.meetingLink,
  ];

  const result = await pool.query(query, values);
  return result.rows[0];
}

// Get event by Google Event ID
export async function getEventByGoogleId(googleEventId) {
  const query = 'SELECT * FROM calendar_events WHERE google_event_id = $1';
  const result = await pool.query(query, [googleEventId]);
  return result.rows[0];
}

// Get events by user and date range
export async function getUserEvents(userId, startDate, endDate) {
  const query = `
    SELECT * FROM calendar_events
    WHERE user_id = $1
      AND event_start >= $2
      AND event_start <= $3
    ORDER BY event_start ASC
  `;

  const result = await pool.query(query, [userId, startDate, endDate]);
  return result.rows;
}

// Update event status
export async function updateEventStatus(googleEventId, status) {
  const query = `
    UPDATE calendar_events
    SET event_status = $1, updated_at = CURRENT_TIMESTAMP
    WHERE google_event_id = $2
    RETURNING *
  `;

  const result = await pool.query(query, [status, googleEventId]);
  return result.rows[0];
}

export default pool;

utils/dateHelpers.js — Date/timezone utilities:

javascript
import { format, parseISO, addMinutes, isAfter, isBefore } from 'date-fns';
import { formatInTimeZone, toDate } from 'date-fns-tz';

// Convert to ISO string with timezone
export function toISOWithTimezone(date, timezone = 'UTC') {
  return formatInTimeZone(date, timezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
}

// Format date for display
export function formatDisplayDate(date, timezone = 'America/New_York') {
  return formatInTimeZone(parseISO(date), timezone, 'MMM dd, yyyy h:mm a zzz');
}

// Check for scheduling conflicts
export function hasConflict(newEvent, existingEvents) {
  const newStart = parseISO(newEvent.start);
  const newEnd = parseISO(newEvent.end);

  return existingEvents.some(event => {
    const existingStart = parseISO(event.event_start);
    const existingEnd = parseISO(event.event_end);

    // Check for overlap
    return (
      (isAfter(newStart, existingStart) && isBefore(newStart, existingEnd)) ||
      (isAfter(newEnd, existingStart) && isBefore(newEnd, existingEnd)) ||
      (isBefore(newStart, existingStart) && isAfter(newEnd, existingEnd))
    );
  });
}

// Generate available time slots
export function generateTimeSlots(date, startHour, endHour, durationMinutes, existingEvents) {
  const slots = [];
  const baseDate = parseISO(date);

  for (let hour = startHour; hour < endHour; hour++) {
    for (let minute = 0; minute < 60; minute += durationMinutes) {
      const slotStart = new Date(baseDate);
      slotStart.setHours(hour, minute, 0, 0);
      
      const slotEnd = addMinutes(slotStart, durationMinutes);

      // Check if slot conflicts with existing events
      const slot = {
        start: slotStart.toISOString(),
        end: slotEnd.toISOString(),
      };

      if (!hasConflict(slot, existingEvents)) {
        slots.push({
          start: slotStart.toISOString(),
          end: slotEnd.toISOString(),
          available: true,
        });
      }
    }
  }

  return slots;
}

export default {
  toISOWithTimezone,
  formatDisplayDate,
  hasConflict,
  generateTimeSlots,
};

Step 3: Core Logic

api/googleCalendar.js — Google Calendar API client:

javascript
import { google } from 'googleapis';
import dotenv from 'dotenv';
import { getUserTokens, saveUserTokens } from '../db/database.js';

dotenv.config();

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

// Generate OAuth URL for user authorization
export function generateAuthUrl(userId) {
  const scopes = [
    'https://www.googleapis.com/auth/calendar',
    'https://www.googleapis.com/auth/calendar.events',
  ];

  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: scopes,
    state: userId.toString(), // Pass userId in state
    prompt: 'consent', // Force consent to get refresh token
  });

  return authUrl;
}

// Exchange authorization code for tokens
export async function getTokensFromCode(code) {
  const { tokens } = await oauth2Client.getToken(code);
  return tokens;
}

// Get authenticated calendar client for user
async function getCalendarClient(userId) {
  const userTokens = await getUserTokens(userId);

  if (!userTokens) {
    throw new Error('User not authenticated with Google');
  }

  // Check if token is expired
  const now = new Date();
  const expiry = new Date(userTokens.token_expiry);

  if (now >= expiry && userTokens.refresh_token) {
    // Refresh token
    oauth2Client.setCredentials({
      refresh_token: userTokens.refresh_token,
    });

    const { credentials } = await oauth2Client.refreshAccessToken();
    
    // Save new tokens
    await saveUserTokens(userId, userTokens.email, credentials);

    oauth2Client.setCredentials(credentials);
  } else {
    oauth2Client.setCredentials({
      access_token: userTokens.access_token,
      refresh_token: userTokens.refresh_token,
    });
  }

  return google.calendar({ version: 'v3', auth: oauth2Client });
}

// Create calendar event
export async function createCalendarEvent(userId, eventDetails) {
  try {
    const calendar = await getCalendarClient(userId);

    const event = {
      summary: eventDetails.title,
      description: eventDetails.description || '',
      start: {
        dateTime: eventDetails.startTime,
        timeZone: eventDetails.timezone || 'UTC',
      },
      end: {
        dateTime: eventDetails.endTime,
        timeZone: eventDetails.timezone || 'UTC',
      },
      attendees: eventDetails.attendees || [],
      conferenceData: eventDetails.createMeetLink ? {
        createRequest: {
          requestId: `meeting-${Date.now()}`,
          conferenceSolutionKey: { type: 'hangoutsMeet' },
        },
      } : undefined,
      reminders: {
        useDefault: false,
        overrides: [
          { method: 'email', minutes: 24 * 60 },
          { method: 'popup', minutes: 30 },
        ],
      },
    };

    const response = await calendar.events.insert({
      calendarId: 'primary',
      resource: event,
      conferenceDataVersion: eventDetails.createMeetLink ? 1 : 0,
      sendUpdates: 'all', // Send email notifications to attendees
    });

    console.log(`✓ Calendar event created: ${response.data.id}`);

    return {
      eventId: response.data.id,
      meetLink: response.data.hangoutLink || response.data.conferenceData?.entryPoints?.[0]?.uri,
      htmlLink: response.data.htmlLink,
    };
  } catch (error) {
    console.error('Error creating calendar event:', error.message);
    throw error;
  }
}

// Get events for a date range
export async function getEvents(userId, timeMin, timeMax) {
  try {
    const calendar = await getCalendarClient(userId);

    const response = await calendar.events.list({
      calendarId: 'primary',
      timeMin: timeMin,
      timeMax: timeMax,
      singleEvents: true,
      orderBy: 'startTime',
    });

    return response.data.items || [];
  } catch (error) {
    console.error('Error fetching events:', error.message);
    throw error;
  }
}

// Update calendar event
export async function updateCalendarEvent(userId, eventId, updates) {
  try {
    const calendar = await getCalendarClient(userId);

    // First get the existing event
    const existingEvent = await calendar.events.get({
      calendarId: 'primary',
      eventId: eventId,
    });

    // Merge updates
    const updatedEvent = {
      ...existingEvent.data,
      summary: updates.title || existingEvent.data.summary,
      description: updates.description || existingEvent.data.description,
      start: updates.startTime ? {
        dateTime: updates.startTime,
        timeZone: updates.timezone || 'UTC',
      } : existingEvent.data.start,
      end: updates.endTime ? {
        dateTime: updates.endTime,
        timeZone: updates.timezone || 'UTC',
      } : existingEvent.data.end,
    };

    const response = await calendar.events.update({
      calendarId: 'primary',
      eventId: eventId,
      resource: updatedEvent,
      sendUpdates: 'all',
    });

    console.log(`✓ Calendar event updated: ${eventId}`);
    return response.data;
  } catch (error) {
    console.error('Error updating event:', error.message);
    throw error;
  }
}

// Cancel calendar event
export async function cancelCalendarEvent(userId, eventId) {
  try {
    const calendar = await getCalendarClient(userId);

    await calendar.events.delete({
      calendarId: 'primary',
      eventId: eventId,
      sendUpdates: 'all',
    });

    console.log(`✓ Calendar event deleted: ${eventId}`);
    return true;
  } catch (error) {
    console.error('Error deleting event:', error.message);
    throw error;
  }
}

// Setup webhook for calendar changes
export async function setupCalendarWebhook(userId) {
  try {
    const calendar = await getCalendarClient(userId);

    const channelId = `calendar-${userId}-${Date.now()}`;
    const expiration = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days

    const response = await calendar.events.watch({
      calendarId: 'primary',
      requestBody: {
        id: channelId,
        type: 'web_hook',
        address: process.env.GOOGLE_CALENDAR_WEBHOOK_URL,
        expiration: expiration.toString(),
      },
    });

    console.log(`✓ Webhook setup for user ${userId}`);

    return {
      channelId: response.data.id,
      resourceId: response.data.resourceId,
      expiration: new Date(parseInt(response.data.expiration)),
    };
  } catch (error) {
    console.error('Error setting up webhook:', error.message);
    throw error;
  }
}

export default {
  generateAuthUrl,
  getTokensFromCode,
  createCalendarEvent,
  getEvents,
  updateCalendarEvent,
  cancelCalendarEvent,
  setupCalendarWebhook,
};

api/crm.js — CRM integration (HubSpot example):

javascript
import axios from 'axios';
import dotenv from 'dotenv';

dotenv.config();

const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY;
const CRM_TYPE = process.env.CRM_TYPE || 'hubspot';

// Create activity/meeting in CRM
export async function createCRMActivity(activityData) {
  if (CRM_TYPE === 'hubspot') {
    return createHubSpotEngagement(activityData);
  }
  // Add other CRM types here
  throw new Error(`CRM type ${CRM_TYPE} not supported`);
}

// HubSpot: Create engagement (meeting)
async function createHubSpotEngagement(data) {
  try {
    const engagement = {
      engagement: {
        active: true,
        type: 'MEETING',
        timestamp: new Date(data.startTime).getTime(),
      },
      associations: {
        contactIds: [data.contactId],
      },
      metadata: {
        title: data.title,
        body: data.description || '',
        startTime: new Date(data.startTime).getTime(),
        endTime: new Date(data.endTime).getTime(),
        meetingOutcome: null,
      },
    };

    const response = await axios.post(
      'https://api.hubapi.com/engagements/v1/engagements',
      engagement,
      {
        params: { hapikey: HUBSPOT_API_KEY },
        headers: { 'Content-Type': 'application/json' },
      }
    );

    console.log(`✓ HubSpot engagement created: ${response.data.engagement.id}`);

    return {
      activityId: response.data.engagement.id.toString(),
      crmType: 'hubspot',
    };
  } catch (error) {
    console.error('Error creating HubSpot engagement:', error.response?.data || error.message);
    throw error;
  }
}

// Update CRM activity
export async function updateCRMActivity(activityId, updates) {
  if (CRM_TYPE === 'hubspot') {
    return updateHubSpotEngagement(activityId, updates);
  }
  throw new Error(`CRM type ${CRM_TYPE} not supported`);
}

async function updateHubSpotEngagement(engagementId, updates) {
  try {
    const engagement = {
      engagement: {
        timestamp: updates.startTime ? new Date(updates.startTime).getTime() : undefined,
      },
      metadata: {
        title: updates.title,
        body: updates.description,
        startTime: updates.startTime ? new Date(updates.startTime).getTime() : undefined,
        endTime: updates.endTime ? new Date(updates.endTime).getTime() : undefined,
      },
    };

    const response = await axios.patch(
      `https://api.hubapi.com/engagements/v1/engagements/${engagementId}`,
      engagement,
      {
        params: { hapikey: HUBSPOT_API_KEY },
      }
    );

    console.log(`✓ HubSpot engagement updated: ${engagementId}`);
    return response.data;
  } catch (error) {
    console.error('Error updating HubSpot engagement:', error.response?.data || error.message);
    throw error;
  }
}

// Delete/cancel CRM activity
export async function deleteCRMActivity(activityId) {
  if (CRM_TYPE === 'hubspot') {
    return deleteHubSpotEngagement(activityId);
  }
  throw new Error(`CRM type ${CRM_TYPE} not supported`);
}

async function deleteHubSpotEngagement(engagementId) {
  try {
    await axios.delete(
      `https://api.hubapi.com/engagements/v1/engagements/${engagementId}`,
      {
        params: { hapikey: HUBSPOT_API_KEY },
      }
    );

    console.log(`✓ HubSpot engagement deleted: ${engagementId}`);
    return true;
  } catch (error) {
    console.error('Error deleting HubSpot engagement:', error.response?.data || error.message);
    throw error;
  }
}

// Get contact by email
export async function getContactByEmail(email) {
  if (CRM_TYPE === 'hubspot') {
    return getHubSpotContactByEmail(email);
  }
  throw new Error(`CRM type ${CRM_TYPE} not supported`);
}

async function getHubSpotContactByEmail(email) {
  try {
    const response = await axios.get(
      `https://api.hubapi.com/contacts/v1/contact/email/${email}/profile`,
      {
        params: { hapikey: HUBSPOT_API_KEY },
      }
    );

    return {
      contactId: response.data.vid.toString(),
      email: response.data.properties.email.value,
      firstName: response.data.properties.firstname?.value,
      lastName: response.data.properties.lastname?.value,
    };
  } catch (error) {
    if (error.response?.status === 404) {
      return null; // Contact not found
    }
    console.error('Error fetching HubSpot contact:', error.response?.data || error.message);
    throw error;
  }
}

export default {
  createCRMActivity,
  updateCRMActivity,
  deleteCRMActivity,
  getContactByEmail,
};

src/server.js — Main Express server:

javascript
import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import dotenv from 'dotenv';
import { testConnection, saveUserTokens, saveCalendarEvent, getEventByGoogleId, getUserEvents } from '../db/database.js';
import {
  generateAuthUrl,
  getTokensFromCode,
  createCalendarEvent,
  getEvents,
  cancelCalendarEvent,
} from '../api/googleCalendar.js';
import {
  createCRMActivity,
  deleteCRMActivity,
  getContactByEmail,
} from '../api/crm.js';
import { hasConflict, generateTimeSlots } from '../utils/dateHelpers.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 5000;

// Middleware
app.use(cors({ origin: process.env.FRONTEND_URL }));
app.use(bodyParser.json());

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

// OAuth: Start authorization flow
app.get('/oauth/authorize', (req, res) => {
  const { userId } = req.query;

  if (!userId) {
    return res.status(400).json({ error: 'userId required' });
  }

  const authUrl = generateAuthUrl(userId);
  res.json({ authUrl });
});

// OAuth: Handle callback
app.get('/oauth/callback', async (req, res) => {
  const { code, state: userId } = req.query;

  if (!code || !userId) {
    return res.redirect(`${process.env.FRONTEND_URL}/error?message=invalid_oauth`);
  }

  try {
    // Exchange code for tokens
    const tokens = await getTokensFromCode(code);

    // Get user email from token
    const oauth2Client = new google.auth.OAuth2();
    oauth2Client.setCredentials(tokens);
    const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
    const userInfo = await oauth2.userinfo.get();

    // Save tokens
    await saveUserTokens(parseInt(userId), userInfo.data.email, tokens);

    res.redirect(`${process.env.FRONTEND_URL}/success`);
  } catch (error) {
    console.error('OAuth callback error:', error);
    res.redirect(`${process.env.FRONTEND_URL}/error?message=token_exchange_failed`);
  }
});

// POST /api/book-meeting - Book meeting (creates Calendar + CRM entry)
app.post('/api/book-meeting', async (req, res) => {
  try {
    const {
      userId,
      contactEmail,
      title,
      description,
      startTime,
      endTime,
      timezone,
      createMeetLink,
    } = req.body;

    console.log(`\n📅 Booking meeting for ${contactEmail}`);

    // 1. Check for conflicts
    const existingEvents = await getUserEvents(
      userId,
      new Date(startTime),
      new Date(endTime)
    );

    if (hasConflict({ start: startTime, end: endTime }, existingEvents)) {
      return res.status(409).json({
        success: false,
        error: 'Time slot conflicts with existing event',
      });
    }

    // 2. Get or verify contact in CRM
    let contact = await getContactByEmail(contactEmail);

    if (!contact) {
      return res.status(404).json({
        success: false,
        error: 'Contact not found in CRM',
      });
    }

    console.log(`  Contact found: ${contact.contactId}`);

    // 3. Create Google Calendar event
    const calendarEvent = await createCalendarEvent(userId, {
      title,
      description,
      startTime,
      endTime,
      timezone,
      createMeetLink,
      attendees: [{ email: contactEmail }],
    });

    console.log(`  Calendar event: ${calendarEvent.eventId}`);

    // 4. Create CRM activity
    const crmActivity = await createCRMActivity({
      contactId: contact.contactId,
      title,
      description: `${description}\n\nMeeting Link: ${calendarEvent.meetLink || 'N/A'}`,
      startTime,
      endTime,
    });

    console.log(`  CRM activity: ${crmActivity.activityId}`);

    // 5. Save mapping to database
    await saveCalendarEvent({
      userId,
      googleEventId: calendarEvent.eventId,
      crmActivityId: crmActivity.activityId,
      crmType: crmActivity.crmType,
      contactId: contact.contactId,
      contactEmail,
      eventTitle: title,
      eventStart: startTime,
      eventEnd: endTime,
      eventStatus: 'confirmed',
      meetingLink: calendarEvent.meetLink,
    });

    console.log('✓ Meeting booked successfully\n');

    res.json({
      success: true,
      data: {
        calendarEventId: calendarEvent.eventId,
        crmActivityId: crmActivity.activityId,
        meetLink: calendarEvent.meetLink,
        htmlLink: calendarEvent.htmlLink,
      },
    });
  } catch (error) {
    console.error('Error booking meeting:', error);
    res.status(500).json({
      success: false,
      error: error.message,
    });
  }
});

// GET /api/available-slots - Get available time slots
app.get('/api/available-slots', async (req, res) => {
  try {
    const { userId, date, duration = 30 } = req.query;

    if (!userId || !date) {
      return res.status(400).json({ error: 'userId and date required' });
    }

    // Get existing events for the day
    const startOfDay = new Date(date);
    startOfDay.setHours(0, 0, 0, 0);

    const endOfDay = new Date(date);
    endOfDay.setHours(23, 59, 59, 999);

    const existingEvents = await getUserEvents(
      parseInt(userId),
      startOfDay.toISOString(),
      endOfDay.toISOString()
    );

    // Generate available slots (9 AM - 5 PM)
    const slots = generateTimeSlots(
      date,
      9, // start hour
      17, // end hour
      parseInt(duration),
      existingEvents
    );

    res.json({
      success: true,
      date,
      slots,
    });
  } catch (error) {
    console.error('Error getting available slots:', error);
    res.status(500).json({ error: error.message });
  }
});

// POST /api/cancel-meeting - Cancel meeting
app.post('/api/cancel-meeting', async (req, res) => {
  try {
    const { googleEventId } = req.body;

    const event = await getEventByGoogleId(googleEventId);

    if (!event) {
      return res.status(404).json({ error: 'Event not found' });
    }

    // Cancel in Google Calendar
    await cancelCalendarEvent(event.user_id, googleEventId);

    // Cancel in CRM
    if (event.crm_activity_id) {
      await deleteCRMActivity(event.crm_activity_id);
    }

    // Update status in database
    await updateEventStatus(googleEventId, 'cancelled');

    res.json({ success: true });
  } catch (error) {
    console.error('Error canceling meeting:', error);
    res.status(500).json({ error: error.message });
  }
});

// Webhook: Google Calendar changes
app.post('/webhooks/google-calendar', async (req, res) => {
  try {
    const channelId = req.headers['x-goog-channel-id'];
    const resourceState = req.headers['x-goog-resource-state'];

    console.log(`\n📥 Webhook: ${resourceState} from channel ${channelId}`);

    if (resourceState === 'sync') {
      // Initial sync notification
      return res.status(200).send('OK');
    }

    // Handle event changes
    // TODO: Fetch changed events and sync with CRM

    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).send('Error');
  }
});

// Start server
async function startServer() {
  try {
    await testConnection();

    app.listen(PORT, () => {
      console.log(`\n🚀 Server running on http://localhost:${PORT}`);
      console.log(`\nEndpoints:`);
      console.log(`  GET  /oauth/authorize - Start OAuth`);
      console.log(`  POST /api/book-meeting - Book meeting`);
      console.log(`  GET  /api/available-slots - Get time slots`);
      console.log(`  POST /api/cancel-meeting - Cancel meeting\n`);
    });
  } catch (error) {
    console.error('Failed to start:', error);
    process.exit(1);
  }
}

startServer();

frontend/src/components/BookingForm.jsx — Meeting booking UI:

jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { format } from 'date-fns';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';

export default function BookingForm({ userId, salesRepEmail }) {
  const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
  const [availableSlots, setAvailableSlots] = useState([]);
  const [selectedSlot, setSelectedSlot] = useState(null);
  const [formData, setFormData] = useState({
    email: '',
    name: '',
    notes: '',
  });
  const [loading, setLoading] = useState(false);
  const [success, setSuccess] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchAvailableSlots();
  }, [date]);

  async function fetchAvailableSlots() {
    try {
      setLoading(true);
      const response = await axios.get(`${API_URL}/api/available-slots`, {
        params: { userId, date, duration: 30 },
      });

      if (response.data.success) {
        setAvailableSlots(response.data.slots);
      }
    } catch (err) {
      setError('Failed to load available times');
    } finally {
      setLoading(false);
    }
  }

  async function handleSubmit(e) {
    e.preventDefault();

    if (!selectedSlot) {
      setError('Please select a time slot');
      return;
    }

    try {
      setLoading(true);
      setError(null);

      const response = await axios.post(`${API_URL}/api/book-meeting`, {
        userId,
        contactEmail: formData.email,
        title: `Meeting with ${formData.name}`,
        description: formData.notes || 'Sales meeting',
        startTime: selectedSlot.start,
        endTime: selectedSlot.end,
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
        createMeetLink: true,
      });

      if (response.data.success) {
        setSuccess(true);
        window.location.href = response.data.data.htmlLink;
      }
    } catch (err) {
      if (err.response?.status === 404) {
        setError('Email not found in our CRM. Please contact sales.');
      } else if (err.response?.status === 409) {
        setError('Time slot no longer available. Please select another.');
        fetchAvailableSlots();
      } else {
        setError(err.response?.data?.error || 'Failed to book meeting');
      }
    } finally {
      setLoading(false);
    }
  }

  if (success) {
    return (
      <div className="max-w-md mx-auto p-6 bg-green-50 rounded-lg">
        <h2 className="text-2xl font-bold text-green-800 mb-4">
          ✅ Meeting Booked!
        </h2>
        <p>Check your email for the calendar invite.</p>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow">
      <h2 className="text-2xl font-bold mb-6">Schedule a Meeting</h2>

      {error && (
        <div className="mb-4 p-4 bg-red-50 text-red-700 rounded">
          {error}
        </div>
      )}

      <form onSubmit={handleSubmit}>
        {/* Contact Info */}
        <div className="mb-6">
          <label className="block font-medium mb-2">Your Email</label>
          <input
            type="email"
            required
            value={formData.email}
            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            className="w-full px-4 py-2 border rounded"
            placeholder="you@company.com"
          />
        </div>

        <div className="mb-6">
          <label className="block font-medium mb-2">Your Name</label>
          <input
            type="text"
            required
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        {/* Date Selection */}
        <div className="mb-6">
          <label className="block font-medium mb-2">Select Date</label>
          <input
            type="date"
            value={date}
            min={format(new Date(), 'yyyy-MM-dd')}
            onChange={(e) => {
              setDate(e.target.value);
              setSelectedSlot(null);
            }}
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        {/* Time Slots */}
        <div className="mb-6">
          <label className="block font-medium mb-2">Available Times</label>
          {loading ? (
            <p>Loading available times...</p>
          ) : (
            <div className="grid grid-cols-3 gap-2">
              {availableSlots.map((slot, index) => (
                <button
                  key={index}
                  type="button"
                  onClick={() => setSelectedSlot(slot)}
                  className={`px-4 py-2 border rounded ${
                    selectedSlot === slot
                      ? 'bg-blue-600 text-white'
                      : 'bg-white hover:bg-gray-50'
                  }`}
                >
                  {format(new Date(slot.start), 'h:mm a')}
                </button>
              ))}
            </div>
          )}
        </div>

        {/* Notes */}
        <div className="mb-6">
          <label className="block font-medium mb-2">Notes (Optional)</label>
          <textarea
            value={formData.notes}
            onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
            className="w-full px-4 py-2 border rounded"
            rows="3"
          />
        </div>

        <button
          type="submit"
          disabled={loading || !selectedSlot}
          className="w-full py-3 bg-blue-600 text-white rounded font-medium hover:bg-blue-700 disabled:opacity-50"
        >
          {loading ? 'Booking...' : 'Book Meeting'}
        </button>
      </form>
    </div>
  );
}

Step 4: Testing

Test 1: Start Backend

bash
cd backend
npm run dev

Test 2: Initiate OAuth Flow

bash
curl "http://localhost:5000/oauth/authorize?userId=1"

Expected response:

json
{
  "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar..."
}

Open the URL in browser, authorize, verify redirect to callback.

Test 3: Check Available Slots

bash
curl "http://localhost:5000/api/available-slots?userId=1&date=2024-03-20&duration=30"

Expected response:

json
{
  "success": true,
  "date": "2024-03-20",
  "slots": [
    {
      "start": "2024-03-20T13:00:00.000Z",
      "end": "2024-03-20T13:30:00.000Z",
      "available": true
    },
    ...
  ]
}

Test 4: Book Meeting

bash
curl -X POST http://localhost:5000/api/book-meeting \
  -H "Content-Type: application/json" \
  -d '{
    "userId": 1,
    "contactEmail": "prospect@example.com",
    "title": "Product Demo",
    "description": "Demo of product features",
    "startTime": "2024-03-20T14:00:00Z",
    "endTime": "2024-03-20T14:30:00Z",
    "timezone": "America/New_York",
    "createMeetLink": true
  }'

Expected response:

json
{
  "success": true,
  "data": {
    "calendarEventId": "abc123def456",
    "crmActivityId": "789012",
    "meetLink": "https://meet.google.com/xyz-abcd-efg",
    "htmlLink": "https://calendar.google.com/event?eid=..."
  }
}

Test 5: Verify in Google Calendar

  1. Open Google Calendar
  2. Check for new event on selected date/time
  3. Verify attendee email is included
  4. Confirm Google Meet link is attached

Test 6: Verify in CRM (HubSpot)

  1. Log into HubSpot
  2. Find contact by email
  3. Check Activities/Engagements
  4. Verify meeting is logged with correct time

Test 7: Check Database Mapping

bash
psql -U your_username -d calendar_crm
sql
SELECT * FROM calendar_events ORDER BY created_at DESC LIMIT 5;

Verify:

  • ✓ Google event ID stored
  • ✓ CRM activity ID stored
  • ✓ Contact email linked
  • ✓ Times are correct

Testing Checklist:

  • ✓ OAuth flow completes
  • ✓ Tokens saved to database
  • ✓ Available slots return correctly
  • ✓ Meeting creates in Calendar
  • ✓ Meeting creates in CRM
  • ✓ Database mapping saved
  • ✓ Google Meet link generated
  • ✓ Email notifications sent

Common Errors & Troubleshooting

Error 1: “Invalid grant” or “Token has been expired or revoked”

Problem: OAuth tokens expired or invalid, API requests fail with 401.

Solution: Implement automatic token refresh using refresh token:

javascript
// Already implemented in getCalendarClient()
if (now >= expiry && userTokens.refresh_token) {
  oauth2Client.setCredentials({
    refresh_token: userTokens.refresh_token,
  });

  const { credentials } = await oauth2Client.refreshAccessToken();
  await saveUserTokens(userId, userTokens.email, credentials);
}

If refresh token missing:

User must re-authorize. Ensure OAuth flow uses prompt: 'consent' and access_type: 'offline' to always get refresh tokens:

javascript
const authUrl = oauth2Client.generateAuthUrl({
  access_type: 'offline', // Critical for refresh token
  prompt: 'consent', // Force consent screen
  scope: scopes,
});

Test token refresh:

bash
# Check token expiry in database
SELECT token_expiry, refresh_token FROM user_tokens WHERE user_id = 1;

# If expired, next API call should auto-refresh

Error 2: “Contact not found in CRM” (404 Error)

Problem: Booking fails because email doesn’t exist in CRM.

Solution: Add contact creation flow for new prospects:

javascript
// In api/crm.js, add createContact function
export async function createContact(email, firstName, lastName) {
  if (CRM_TYPE === 'hubspot') {
    const contact = {
      properties: {
        email,
        firstname: firstName,
        lastname: lastName,
      },
    };

    const response = await axios.post(
      'https://api.hubapi.com/crm/v3/objects/contacts',
      contact,
      {
        params: { hapikey: HUBSPOT_API_KEY },
      }
    );

    return {
      contactId: response.data.id,
      email,
    };
  }
}

// In server.js, modify book-meeting endpoint
let contact = await getContactByEmail(contactEmail);

if (!contact) {
  // Auto-create contact for new prospects
  const [firstName, lastName] = req.body.name.split(' ');
  contact = await createContact(contactEmail, firstName, lastName || '');
  console.log(`  Contact created: ${contact.contactId}`);
}

Alternative: Return friendly error asking user to contact sales first.

Error 3: “Insufficient Permission” or Scope Errors

Problem: Calendar API requests fail with 403 Forbidden.

Solution: Verify OAuth scopes include necessary permissions:

Required scopes:

javascript
const scopes = [
  'https://www.googleapis.com/auth/calendar', // Full calendar access
  'https://www.googleapis.com/auth/calendar.events', // Event management
];

Check granted scopes in database:

javascript
// When saving tokens, also log scopes
console.log('Granted scopes:', tokens.scope);

If scopes missing:

  1. Update scope list in generateAuthUrl()
  2. Force user re-authorization with prompt: 'consent'
  3. Delete old tokens: DELETE FROM user_tokens WHERE user_id = 1
  4. User must complete OAuth flow again

Verify in Google Cloud Console:

  • Go to APIs & Services → Credentials
  • Check OAuth consent screen
  • Ensure Calendar API is enabled in API Library

Security Checklist

Critical security practices for Google Calendar-CRM integration:

  • Store OAuth tokens encrypted — Use database encryption for access_token and refresh_token columns. Implement at-rest encryption:
sql
  -- PostgreSQL example with pgcrypto
  CREATE EXTENSION IF NOT EXISTS pgcrypto;
  
  -- Encrypt before storing
  INSERT INTO user_tokens (access_token)
  VALUES (pgp_sym_encrypt('token_value', 'encryption_key'));
  • Use HTTPS for OAuth redirect URIs — Google requires HTTPS in production. Update .env:
env
  GOOGLE_REDIRECT_URI=https://yourdomain.com/oauth/callback
  • Implement CSRF protection — Verify state parameter matches during OAuth callback:
javascript
  // Before OAuth, generate and store state
  const state = crypto.randomBytes(16).toString('hex');
  await redis.set(`oauth_state_${userId}`, state, 'EX', 600);
  
  // In callback, verify
  const storedState = await redis.get(`oauth_state_${userId}`);
  if (state !== storedState) {
    throw new Error('Invalid state parameter');
  }
  • Validate webhook signatures — Verify Google Calendar webhook requests:
javascript
  app.post('/webhooks/google-calendar', (req, res) => {
    const channelId = req.headers['x-goog-channel-id'];
    const channelToken = req.headers['x-goog-channel-token'];
    
    // Verify channel belongs to your system
    const validChannel = await getWebhookChannel(channelId);
    if (!validChannel || validChannel.token !== channelToken) {
      return res.status(403).send('Unauthorized');
    }
    // Process webhook...
  });
  • Implement rate limiting — Protect booking endpoint from abuse:
javascript
  import rateLimit from 'express-rate-limit';
  
  const bookingLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5, // Max 5 bookings per 15 minutes per IP
    message: 'Too many booking requests',
  });
  
  app.post('/api/book-meeting', bookingLimiter, async (req, res) => {
    // ...
  });
  • Sanitize user inputs — Validate and escape all booking form data:
javascript
  function sanitizeInput(input) {
    return input.trim().slice(0, 500); // Max length
  }
  
  const title = sanitizeInput(req.body.title);
  const description = sanitizeInput(req.body.description);
  • Restrict calendar access scope — Use minimal OAuth scopes. If only creating events, request calendar.events instead of full calendar access.
  • Log all booking attempts — Track suspicious activity:
javascript
  await pool.query(
    'INSERT INTO booking_audit_log (ip, email, success, error) VALUES ($1, $2, $3, $4)',
    [req.ip, req.body.contactEmail, success, error]
  );
  • Implement token rotation — Refresh tokens before expiry:
javascript
  // Check expiry 1 hour before actual expiration
  if (expiry - Date.now() < 3600000) {
    await refreshAccessToken(userId);
  }
  • Validate timezone inputs — Prevent timezone manipulation attacks:
javascript
  const validTimezones = Intl.supportedValuesOf('timeZone');
  if (!validTimezones.includes(req.body.timezone)) {
    return res.status(400).json({ error: 'Invalid timezone' });
  }
  • Revoke access on account deletion — Clean up tokens when user deletes account:
javascript
  async function revokeGoogleAccess(userId) {
    const tokens = await getUserTokens(userId);
    await oauth2Client.revokeToken(tokens.access_token);
    await pool.query('DELETE FROM user_tokens WHERE user_id = $1', [userId]);
  }

Related Resources:

Leave a Comment

Your email address will not be published. Required fields are marked *