{
  "name": "LL Scheduling Automation",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ll-schedule",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000001",
      "name": "GHL Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [200, 300]
    },
    {
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\nconst contactId = body.contactId || body.contact_id || '';\nconst firstName = body.firstName || body.first_name || '';\nconst lastName = body.lastName || body.last_name || '';\nconst contactName = `${firstName} ${lastName}`.trim() || body.contactName || '';\nconst contactEmail = body.email || body.contactEmail || '';\nconst contactPhone = body.phone || body.contactPhone || '';\nconst noteBody = body.body || body.noteBody || body.note || body.message || '';\n\nif (!noteBody) {\n  return [{ json: { error: 'No note body found in webhook payload', raw: body } }];\n}\n\nconst response = await this.helpers.httpRequest({\n  method: 'POST',\n  url: 'https://api.openai.com/v1/chat/completions',\n  headers: {\n    'Authorization': 'Bearer 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',\n    'Content-Type': 'application/json',\n  },\n  body: {\n    model: 'gpt-4o-mini',\n    max_tokens: 300,\n    response_format: { type: 'json_object' },\n    messages: [{\n      role: 'system',\n      content: 'You parse scheduling notes from a speech therapist into structured JSON. Return ONLY valid JSON with these fields: days (array of lowercase weekday names), preference (\"am\" or \"pm\" or \"any\"), frequency (\"weekly\" or \"biweekly\" or \"2x_week\" or \"3x_week\"), sessions (number, default 8), session_type (\"adult\" or \"myo\", default \"adult\").'\n    }, {\n      role: 'user',\n      content: noteBody\n    }],\n  },\n});\n\nlet parsed = {};\ntry {\n  parsed = JSON.parse(response.choices[0].message.content);\n} catch (e) {\n  parsed = { days: [], preference: 'am', frequency: 'weekly', sessions: 8, session_type: 'adult' };\n}\n\nconst calendarIds = { adult: 'blAjNdl84YudbyBaiSvu', myo: 'RBSNb6Vn1pqiZ6Kmjf3z' };\n\nreturn [{\n  json: {\n    contactId, contactName, contactEmail, contactPhone, firstName, lastName, noteBody,\n    ...parsed,\n    calendarId: calendarIds[parsed.session_type || 'adult'] || calendarIds.adult,\n    duration: (parsed.session_type || 'adult') === 'adult' ? 45 : 30,\n    ptAppointments: {},\n    hasPt: false,\n  }\n}];"
      },
      "id": "a1000000-0000-0000-0000-000000000002",
      "name": "Parse Note with Claude",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [420, 300]
    },
    {
      "parameters": {
        "jsCode": "// Fetch GHL calendar availability in 14-day chunks\nconst data = $input.first().json;\nconst apiKey = 'pit-cd82bbf6-d31f-47cd-8177-662ca2d817e1';\nconst calendarId = data.calendarId;\n\nconst freqMap = { biweekly: 14, weekly: 7, '2x_week': 3, '3x_week': 2 };\nconst freq = freqMap[data.frequency] || 7;\nconst weeksNeeded = Math.ceil((data.sessions * freq) / 7) + 4;\n\nconst start = new Date();\nstart.setDate(start.getDate() + 1);\nstart.setHours(0, 0, 0, 0);\nconst end = new Date(start);\nend.setDate(end.getDate() + weeksNeeded * 7);\n\nconst allSlots = {};\nlet chunkStart = new Date(start);\n\nwhile (chunkStart < end) {\n  const chunkEnd = new Date(Math.min(chunkStart.getTime() + 14 * 86400000, end.getTime()));\n  try {\n    const resp = await this.helpers.httpRequest({\n      method: 'GET',\n      url: `https://services.leadconnectorhq.com/calendars/${calendarId}/free-slots`,\n      headers: { 'Authorization': `Bearer ${apiKey}`, 'Version': '2021-04-15' },\n      qs: { startDate: chunkStart.getTime(), endDate: chunkEnd.getTime(), timezone: 'America/New_York' },\n    });\n    for (const [dateKey, value] of Object.entries(resp)) {\n      if (dateKey === 'traceId') continue;\n      const dateStr = dateKey.substring(0, 10);\n      const slotList = value?.slots || (Array.isArray(value) ? value : []);\n      const times = slotList.filter(s => typeof s === 'string' && s.includes('T')).map(s => s.split('T')[1].substring(0, 5));\n      if (times.length) allSlots[dateStr] = times.sort();\n    }\n  } catch (e) {\n    if (e.message?.includes('429')) {\n      await new Promise(r => setTimeout(r, 5000));\n      try {\n        const resp2 = await this.helpers.httpRequest({\n          method: 'GET',\n          url: `https://services.leadconnectorhq.com/calendars/${calendarId}/free-slots`,\n          headers: { 'Authorization': `Bearer ${apiKey}`, 'Version': '2021-04-15' },\n          qs: { startDate: chunkStart.getTime(), endDate: chunkEnd.getTime(), timezone: 'America/New_York' },\n        });\n        for (const [dk, v] of Object.entries(resp2)) {\n          if (dk === 'traceId') continue;\n          const ds = dk.substring(0, 10);\n          const sl = v?.slots || [];\n          const ts = sl.filter(s => typeof s === 'string' && s.includes('T')).map(s => s.split('T')[1].substring(0, 5));\n          if (ts.length) allSlots[ds] = ts.sort();\n        }\n      } catch (e2) { /* skip chunk */ }\n    }\n  }\n  chunkStart = chunkEnd;\n}\n\nreturn [{ json: { ...data, availableSlots: allSlots, totalSlots: Object.values(allSlots).reduce((s, a) => s + a.length, 0) } }];"
      },
      "id": "a1000000-0000-0000-0000-000000000009",
      "name": "Fetch GHL Slots",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst { availableSlots, ptAppointments, days, preference, frequency, sessions, session_type, duration, calendarId, contactId, contactName, firstName, contactEmail, contactPhone } = data;\n\nconst DAY_MAP = { monday: 0, tuesday: 1, wednesday: 2, thursday: 3, friday: 4, saturday: 5, sunday: 6 };\nconst FREQ = { biweekly: 14, weekly: 7, '2x_week': 3, '3x_week': 2 };\nconst PREF = { am: [480, 720], morning: [480, 720], pm: [720, 1020], afternoon: [720, 1020], any: [480, 1020] };\n\nconst freqDays = FREQ[frequency] || 7;\nconst [prefStart, prefEnd] = PREF[preference] || PREF.any;\nconst allowedDays = new Set((days || []).map(d => DAY_MAP[d.toLowerCase()]).filter(d => d !== undefined));\nif (allowedDays.size === 0) [0,1,2,3,4].forEach(d => allowedDays.add(d));\n\nfunction toMin(t) { const [h, m] = t.split(':').map(Number); return h * 60 + m; }\nfunction inPref(t) { const m = toMin(t); return m >= prefStart && m < prefEnd; }\n\nconst candidates = [];\nfor (const [dateStr, times] of Object.entries(availableSlots)) {\n  const dt = new Date(dateStr + 'T12:00:00');\n  const wd = dt.getDay() === 0 ? 6 : dt.getDay() - 1;\n  const ptOnDate = (ptAppointments || {})[dateStr] || [];\n  const isPtDay = ptOnDate.length > 0;\n  for (const t of times) {\n    candidates.push({\n      date: dateStr, time: t, weekday: wd, isPtDay,\n      onAllowedDay: allowedDays.has(wd),\n      inPref: inPref(t),\n      ptProx: isPtDay ? Math.min(...ptOnDate.map(p => Math.abs(toMin(t) - toMin(p.time)))) : 9999,\n    });\n  }\n}\n\ncandidates.sort((a, b) => {\n  if (a.isPtDay !== b.isPtDay) return a.isPtDay ? -1 : 1;\n  if (a.onAllowedDay !== b.onAllowedDay) return a.onAllowedDay ? -1 : 1;\n  if (a.inPref !== b.inPref) return a.inPref ? -1 : 1;\n  if (a.ptProx !== b.ptProx) return a.ptProx - b.ptProx;\n  if (a.date !== b.date) return a.date < b.date ? -1 : 1;\n  return a.time < b.time ? -1 : 1;\n});\n\nconst selected = [];\nlet windowStart = candidates.length ? new Date(candidates[0].date + 'T00:00:00') : new Date();\nlet remaining = [...candidates];\n\nlet maxAttempts = sessions * 4;\nlet attempts = 0;\nwhile (selected.length < sessions && remaining.length > 0 && attempts < maxAttempts) {\n  attempts++;\n  const windowEnd = new Date(windowStart.getTime() + freqDays * 86400000);\n  let inWindow = remaining.filter(c => { const d = new Date(c.date + 'T00:00:00'); return d >= windowStart && d < windowEnd; });\n  if (!inWindow.length) {\n    windowStart = windowEnd;\n    const future = remaining.filter(c => new Date(c.date + 'T00:00:00') >= windowStart);\n    if (!future.length) break;\n    windowStart = new Date(future[0].date + 'T00:00:00');\n    continue;\n  }\n  inWindow.sort((a, b) => {\n    if (a.isPtDay !== b.isPtDay) return a.isPtDay ? -1 : 1;\n    if (a.onAllowedDay !== b.onAllowedDay) return a.onAllowedDay ? -1 : 1;\n    if (a.inPref !== b.inPref) return a.inPref ? -1 : 1;\n    if (a.ptProx !== b.ptProx) return a.ptProx - b.ptProx;\n    return a.date < b.date ? -1 : 1;\n  });\n  const best = inWindow[0];\n  selected.push({ date: best.date, time: best.time, ptMatch: best.isPtDay });\n  remaining = remaining.filter(c => c.date !== best.date);\n  windowStart = new Date(new Date(best.date + 'T00:00:00').getTime() + freqDays * 86400000);\n}\n\nselected.sort((a, b) => a.date < b.date ? -1 : 1);\n\nconst typeLabel = (session_type || 'adult') === 'adult' ? 'Adult Speech Therapy (45 min)' : 'Myofunctional Therapy (30 min)';\nconst fn = firstName || (contactName || '').split(' ')[0] || 'there';\nconst ptMatched = selected.filter(s => s.ptMatch).length;\n\nlet amandaSms = `Booked ${fn}: ${selected.length} ${session_type || 'adult'} sessions.`;\nif (ptMatched > 0) amandaSms += ` ${ptMatched}/${selected.length} matched to PT days.`;\nelse amandaSms += ' No PT schedule on file.';\nif (selected.length) {\n  const f = new Date(selected[0].date + 'T12:00:00');\n  const l = new Date(selected[selected.length-1].date + 'T12:00:00');\n  amandaSms += ` ${f.toLocaleDateString('en-US', {month:'short',day:'numeric'})} - ${l.toLocaleDateString('en-US', {month:'short',day:'numeric'})}.`;\n}\n\nlet patientSms = `Hi ${fn}, your ${typeLabel.split('(')[0].trim()} sessions are booked at Lasting Language. `;\npatientSms += `${selected.length} sessions starting ${selected.length ? new Date(selected[0].date+'T12:00:00').toLocaleDateString('en-US',{month:'long',day:'numeric'}) : 'TBD'}. `;\npatientSms += 'Confirmation email with your full schedule is on the way. Call (470) 851-4988 with questions.';\n\nlet html = `<div style=\"font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;\">`;\nhtml += `<p>Hi ${fn},</p><p>Your <strong>${typeLabel}</strong> sessions have been scheduled at Lasting Language Therapy Services.</p>`;\nhtml += `<table style=\"border-collapse:collapse;width:100%;margin:16px 0;\"><tr style=\"background:#f0f4f8;\"><th style=\"padding:8px 12px;text-align:left;border-bottom:2px solid #cbd5e0;\">#</th><th style=\"padding:8px 12px;text-align:left;border-bottom:2px solid #cbd5e0;\">Date</th><th style=\"padding:8px 12px;text-align:left;border-bottom:2px solid #cbd5e0;\">Time</th></tr>`;\nfor (let i = 0; i < selected.length; i++) {\n  const a = selected[i];\n  const dt = new Date(a.date + 'T12:00:00');\n  const [h, m] = a.time.split(':').map(Number);\n  const ap = h >= 12 ? 'PM' : 'AM';\n  const h12 = h > 12 ? h - 12 : (h === 0 ? 12 : h);\n  const bg = i % 2 === 0 ? '#fff' : '#f7fafc';\n  html += `<tr style=\"background:${bg};\"><td style=\"padding:8px 12px;border-bottom:1px solid #e2e8f0;\">${i+1}</td><td style=\"padding:8px 12px;border-bottom:1px solid #e2e8f0;\">${dt.toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}</td><td style=\"padding:8px 12px;border-bottom:1px solid #e2e8f0;\">${h12}:${String(m).padStart(2,'0')} ${ap}</td></tr>`;\n}\nhtml += `</table><p><strong>Location:</strong> 6667 Vernon Woods Dr NE, Suite B-16, Sandy Springs, GA 30328</p>`;\nhtml += `<p style=\"color:#666;font-size:14px;\">We are located on the back side of the B building, along the fence line adjacent to the Toyota dealership.</p>`;\nhtml += `<p>If you need to reschedule, call <strong>(470) 851-4988</strong> or reply to this email.</p>`;\nhtml += `<p>We look forward to seeing you!</p><p><strong>Lasting Language Therapy Services</strong><br>(470) 851-4988</p></div>`;\n\nreturn [{ json: {\n  contactId, contactName, contactEmail, contactPhone, calendarId, firstName,\n  selected, slotDatetimes: selected.map(s => `${s.date}T${s.time}:00`),\n  amandaSms, patientSms, emailHtml: html,\n  emailSubject: `Your ${typeLabel} Schedule — Lasting Language`,\n  sessionsBooked: selected.length, sessionsRequested: sessions, ptMatched,\n} }];"
      },
      "id": "a1000000-0000-0000-0000-000000000010",
      "name": "Schedule",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [860, 300]
    },
    {
      "parameters": {
        "jsCode": "// Book all appointments via GHL API\nconst data = $input.first().json;\nconst apiKey = 'pit-cd82bbf6-d31f-47cd-8177-662ca2d817e1';\nconst locationId = '8HGLSPECfIaQfJaFO7Ef';\nconst results = [];\n\nfor (const slotDt of data.slotDatetimes) {\n  try {\n    const resp = await this.helpers.httpRequest({\n      method: 'POST',\n      url: 'https://services.leadconnectorhq.com/calendars/events/appointments',\n      headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Version': '2021-04-15' },\n      body: { calendarId: data.calendarId, locationId, contactId: data.contactId, startTime: slotDt, timezone: 'America/New_York' },\n    });\n    results.push({ slot: slotDt, status: 'booked' });\n  } catch (err) {\n    results.push({ slot: slotDt, status: 'error', error: err.message });\n  }\n}\n\nconst booked = results.filter(r => r.status === 'booked').length;\nreturn [{ json: { ...data, bookingResults: results, booked, errors: results.length - booked } }];"
      },
      "id": "a1000000-0000-0000-0000-000000000011",
      "name": "Book Appointments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1080, 300]
    },
    {
      "parameters": {
        "jsCode": "// Send confirmation email + SMS to patient, SMS to Amanda\nconst data = $input.first().json;\nconst apiKey = 'pit-cd82bbf6-d31f-47cd-8177-662ca2d817e1';\nconst locationId = '8HGLSPECfIaQfJaFO7Ef';\n\nconst results = { email: null, patientSms: null, amandaSms: null };\n\nif (data.booked === 0) {\n  return [{ json: { status: 'no_bookings', booked: 0, contactName: data.contactName, notifications: results } }];\n}\n\n// 1. Email to patient\ntry {\n  await this.helpers.httpRequest({\n    method: 'POST',\n    url: 'https://services.leadconnectorhq.com/conversations/messages',\n    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Version': '2021-07-28' },\n    body: { type: 'Email', contactId: data.contactId, subject: data.emailSubject, html: data.emailHtml },\n  });\n  results.email = 'sent';\n} catch (e) { results.email = 'error: ' + e.message; }\n\n// 2. SMS to patient\ntry {\n  await this.helpers.httpRequest({\n    method: 'POST',\n    url: 'https://services.leadconnectorhq.com/conversations/messages',\n    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Version': '2021-07-28' },\n    body: { type: 'SMS', contactId: data.contactId, message: data.patientSms },\n  });\n  results.patientSms = 'sent';\n} catch (e) { results.patientSms = 'error: ' + e.message; }\n\n// 3. Amanda confirmation SMS — search for Amanda or use known contact\ntry {\n  const search = await this.helpers.httpRequest({\n    method: 'GET',\n    url: 'https://services.leadconnectorhq.com/contacts/',\n    headers: { 'Authorization': `Bearer ${apiKey}`, 'Version': '2021-07-28' },\n    qs: { locationId, query: 'Amanda Smith' },\n  });\n  const amanda = search.contacts?.[0];\n  if (amanda) {\n    await this.helpers.httpRequest({\n      method: 'POST',\n      url: 'https://services.leadconnectorhq.com/conversations/messages',\n      headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Version': '2021-07-28' },\n      body: { type: 'SMS', contactId: amanda.id, message: data.amandaSms },\n    });\n    results.amandaSms = 'sent';\n  } else {\n    results.amandaSms = 'amanda_not_found';\n  }\n} catch (e) { results.amandaSms = 'error: ' + e.message; }\n\nreturn [{ json: {\n  status: 'success',\n  booked: data.booked,\n  errors: data.errors,\n  sessionsRequested: data.sessionsRequested,\n  contactName: data.contactName,\n  amandaSms: data.amandaSms,\n  notifications: results,\n  schedule: data.selected,\n} }];"
      },
      "id": "a1000000-0000-0000-0000-000000000012",
      "name": "Send Notifications",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1300, 300]
    }
  ],
  "connections": {
    "GHL Webhook": {
      "main": [[{ "node": "Parse Note with Claude", "type": "main", "index": 0 }]]
    },
    "Parse Note with Claude": {
      "main": [[{ "node": "Fetch GHL Slots", "type": "main", "index": 0 }]]
    },
    "Fetch GHL Slots": {
      "main": [[{ "node": "Schedule", "type": "main", "index": 0 }]]
    },
    "Schedule": {
      "main": [[{ "node": "Book Appointments", "type": "main", "index": 0 }]]
    },
    "Book Appointments": {
      "main": [[{ "node": "Send Notifications", "type": "main", "index": 0 }]]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
