Stage 03 of 05
Scheduling
Bot
HOT leads get 3 real calendar slots emailed within seconds of qualification. One click books the meeting, creates the Google Meet link, and fires the Payment Bot.
The Pipeline
10 steps from HOT lead to booked meeting
The Scheduling Bot (B3) is a BullMQ worker on bot-scheduling. It only processes scheduling.book jobs — a name guard at line 1 skips everything else.
Trigger: scheduling.book
The Qualification Bot enqueues this job the moment a lead scores HOT (≥ 75). The Scheduling Bot picks it from the bot-scheduling BullMQ queue — a job-name guard ensures it ignores everything else.
Fetch lead from DB
Read the full lead record from Postgres — email, name, company, BANT score. All context was already captured by Intake and scored by Qualification. No re-extraction.
Google OAuth token refresh
Exchange the stored refresh token for a short-lived access token via oauth2.googleapis.com. The token lasts 3600 seconds — refreshed on every job to avoid expiry mid-flow.
freeBusy calendar query
Call Google Calendar API freeBusy.query for the sales rep's calendar over the next 7 days. Returns all booked time blocks. If this call fails → ESC-SCH-002.
Generate 3 free 45-min slots
Invert the busy blocks against business hours (9:00–17:00, Mon–Fri) over the next 5 business days. Pick the first 3 clean 45-minute windows. Slot hours: 9, 10, 11, 13, 14, 15, 16.
DB write: AWAITING_SLOT
Atomically write status=AWAITING_SLOT, pendingSlots (JSON array of start/end ISO strings), slotSentAt=now() to the Lead row. Audit log entry written.
Send slot-selection email
SMTP email to the lead with 3 clickable magic links — one per slot. Each link hits GET /api/integrations/scheduling/confirm?lead=ID&slot=N. No login required. Clicking = confirmed.
Lead clicks a magic link
The NestJS confirm endpoint validates the lead, checks the slot is still free (ESC-SCH-001 race guard), then calls Google Calendar events.insert with conferenceDataVersion=1 to generate a real Meet link.
DB write: BOOKED
Update Lead: meetingBooked=true, meetingTime, meetingLink (real Google Meet URL), status=BOOKED, pendingSlots=null. The lead sees a confirmation HTML page with the Meet link.
Enqueue Payment Bot
Add payment.request job to bot-payment queue. The Payment Bot (B4) takes over — generates a Stripe checkout link and emails it to the lead within seconds of meeting confirmation.
Google Calendar Integration
3 API calls, full meeting lifecycle
No third-party scheduling SDK — direct Google Calendar REST API calls using OAuth 2.0. The refresh token is stored in env; a short-lived access token is minted per-job.
POST oauth2.googleapis.com/token
Exchange refresh_token → access_token (3600s TTL). Uses GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + GOOGLE_REFRESH_TOKEN.
POST calendar/v3/freeBusy
Query busy blocks for SALES_REP_EMAIL over next 7 days. Returns array of {start, end} ISO strings. Used to generate conflict-free slots.
POST calendar/v3/calendars/primary/events?conferenceDataVersion=1
Insert event with lead + sales rep as attendees. conferenceDataVersion=1 auto-generates a Google Meet link in conferenceData.entryPoints.
// Slot generation — bots/scheduling/src/index.ts
// 1. Refresh token → access token
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: process.env.GOOGLE_REFRESH_TOKEN,
grant_type: 'refresh_token',
}),
})
const { access_token } = await res.json()
// 2. freeBusy query — 7-day window
const busy = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', {
method: 'POST',
headers: { Authorization: `Bearer ${access_token}` },
body: JSON.stringify({
timeMin: now.toISOString(),
timeMax: sevenDaysLater.toISOString(),
items: [{ id: process.env.SALES_REP_EMAIL }],
}),
})
// 3. Invert busy blocks → 3 free 45-min slots
const slots = generateFreeSlots(busyTimes) // next 5 biz days, hours: 9-16The Slot Email
Magic links — no login, no forms
The lead receives an HTML email with 3 pre-built booking links. Clicking any link immediately confirms that slot — no redirect to a booking page, no account creation. The confirmation is instant.
The magic link is a GET request so it works directly from any email client. The URL encodes both the lead ID and the slot index — no session or cookie needed.
<div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px;">
<h2 style="color: #2563eb;">Hi {leadName},</h2>
<p>You've been identified as a HOT lead! Let's schedule a 45-minute discovery call.</p>
<p>Pick any open time below to instantly confirm:</p>
<!-- Slot 1 -->
<a href="{apiBase}/integrations/scheduling/confirm?lead={leadId}&slot=0"
style="display:inline-block; padding:12px 24px; background:#2563eb;
color:white; border-radius:6px; margin-bottom:12px;">
Monday, July 7 · 9:00 AM UTC
</a>
<!-- Slot 2 -->
<a href="{apiBase}/integrations/scheduling/confirm?lead={leadId}&slot=1" ...>
Monday, July 7 · 10:00 AM UTC
</a>
<!-- Slot 3 -->
<a href="{apiBase}/integrations/scheduling/confirm?lead={leadId}&slot=2" ...>
Tuesday, July 8 · 9:00 AM UTC
</a>
<p style="color:#666; font-size:0.9em;">
Clicking any link immediately confirms the slot and generates a Google Meet invite.
</p>
</div>The Confirm Endpoint
GET /api/integrations/scheduling/confirm
NestJS handles the magic link click. It validates the lead, checks for slot conflicts, creates the calendar event, updates the DB, and renders an HTML confirmation page — all in a single synchronous request.
// apps/api/src/modules/integrations/integrations.service.ts
// GET /api/integrations/scheduling/confirm?lead=LEAD_ID&slot=N 1. Find lead by ID — 404 if not found 2. Guard: if status === 'BOOKED' → show "Already scheduled" page 3. Guard: if pendingSlots === null → show "Slots expired" page 4. Select slot[N] from pendingSlots JSON 5. ESC-SCH-001: freeBusy check — is this slot still free? → If BUSY: re-enqueue scheduling.book, show "Slot taken" page 6. POST /calendar/v3/calendars/primary/events?conferenceDataVersion=1 → attendees: [leadEmail, salesRepEmail] → conferenceData.createRequest → Google Meet link 7. DB update: meetingBooked = true meetingTime = slot.start meetingLink = event.conferenceData.entryPoints[video].uri status = 'BOOKED' pendingSlots = null 8. Audit log: scheduling.meeting_booked 9. Enqueue: bot-payment / payment.request 10. Render success HTML with Meet link button
Happy path response
Renders a branded HTML page: "Meeting Confirmed!" with the Google Meet link as a blue button. The lead can click straight into the call from this page.
Error states
Each failure renders a distinct HTML page: "Already Scheduled", "Slot Conflict", "Slots Expired", "Invalid Request" — each with a clear explanation and next step.
DB Schema Changes
Fields added to the Lead model
Stage 03 adds 3 fields to track the scheduling state machine. The DB is synced via prisma db push — no migration files needed for dev.
| field | type | values / purpose | added in |
|---|---|---|---|
| status | LeadStatus | AWAITING_SLOT | BOOKED | PENDING | Stage 03 |
| pendingSlots | Json? | [{start: ISO, end: ISO}, ...] | Stage 03 |
| slotSentAt | DateTime? | timestamp when email was sent | Stage 03 |
| meetingBooked | Boolean | true after GCal event created | Stage 01 |
| meetingTime | DateTime? | confirmed slot start time | Stage 01 |
| meetingLink | String? | Google Meet URL | Stage 01 |
Lead status state machine
Environment Variables
10 vars required for Stage 03
All credentials live in .env at the repo root. The scheduling bot loads them via dotenv.config() at startup. NestJS loads them via ConfigModule.forRoot().
| variable | required | description |
|---|---|---|
| GOOGLE_CLIENT_ID | yes | OAuth 2.0 client from Google Cloud Console |
| GOOGLE_CLIENT_SECRET | yes | OAuth 2.0 secret from Google Cloud Console |
| GOOGLE_REFRESH_TOKEN | yes | Long-lived token from OAuth Playground (scope: calendar) |
| SALES_REP_EMAIL | yes | Gmail/Workspace address whose calendar is queried + booked into |
| SMTP_HOST | yes | SMTP server hostname for sending slot emails |
| SMTP_PORT | yes | 587 (STARTTLS) or 465 (SSL) |
| SMTP_USER | yes | SMTP authentication username |
| SMTP_PASS | yes | SMTP authentication password |
| EMAIL_FROM | yes | From address shown on slot-selection emails |
| NEXT_PUBLIC_API_BASE_URL | yes | Base URL for magic link confirm endpoint |
GOOGLE_REFRESH_TOKEN expires in 7 days when the Google Cloud app is in Testing mode. Publish the OAuth consent screen to Production to get a non-expiring token. Go to: Cloud Console → APIs & Services → OAuth consent screen → Publish App.
Escalation Matrix
2 failure modes, both handled
Trigger: Lead clicks a slot that was just booked by another meeting
Action: Re-enqueue scheduling.book immediately. Lead sees "Slot taken — new options incoming". A fresh freeBusy query generates new slots and a new email is sent.
Trigger: Google Calendar API returns 5xx or token refresh fails
Action: Lead status reset to PENDING. BullMQ retries up to 4 times with exponential backoff. Audit log records escalation. Admin alert logged with full error context.
What Happens Next
Scheduling completes → Payment starts
The moment the meeting is confirmed the confirm endpoint enqueues a payment.request job on the bot-payment queue. The Payment Bot (B4) generates a Stripe checkout link and emails it to the lead within seconds.
See it live
Submit a HOT lead — get a real slot email
Submit via the capture form. The Qualification Bot scores it HOT. Within seconds the Scheduling Bot emails 3 Google Calendar slots. Click one to confirm and get a real Meet link.