Skip to content
// Decide the next step: ask the next question, offer slots, book a slot, or close politely.
const s = $('Settings (edit me)').first().json;
const x = $input.first().json;
if (!x.ok) return [{ json: x }];
const db = $getWorkflowStaticData('global');
const lead = db.leads[x.lead_id];
const now = DateTime.fromISO(x.now);
const first = (lead.name || '').split(' ')[0] || 'there';

// Merge answers (budget from a form may be text like "$5k-10k")
const num = (v) => { if (typeof v === 'number') return v; const m = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\d+(?:\.\d+)?)\s*(k)?/g)].map((g) => Number(g[1]) * (g[2] ? 1000 : 1)); return m.length ? Math.max(...m) : null; };
const a = lead.answers;
if (!['offered', 'booked'].includes(lead.status)) Object.assign(a, x.extracted); // answers are frozen once slots are offered
const toWeeks = (v) => {
  if (typeof v === 'number') return v;
  const t = String(v ?? '').toLowerCase();
  for (const [re, w] of [[/asap|right away|immediately|this week|urgent/, 1], [/next week/, 2], [/this month/, 3], [/next month/, 6], [/quarter/, 12], [/next year|just (researching|looking|exploring)|no rush/, 52]]) if (re.test(t)) return w;
  const m = t.match(/(\d+)\s*(day|week|month)/); if (m) return Math.ceil(Number(m[1]) * { day: 1 / 7, week: 1, month: 4 }[m[2]]);
  return null;
};
if (a.budget !== undefined) a.budget = num(a.budget);
if (a.timeline !== undefined && (a.timeline_weeks === undefined || a.timeline_weeks === null)) a.timeline_weeks = toWeeks(a.timeline);
if (typeof a.decision_maker === 'string') a.decision_maker = /^(y|yes|true|1)/i.test(a.decision_maker);

// Score with reasons
const reasons = [];
let score = 0;
if (a.need) { score += 30; reasons.push(`need: ${a.need}`); }
if (a.budget != null) { if (a.budget >= s.min_budget) { score += 30; reasons.push(`budget $${a.budget} ≥ $${s.min_budget}`); } else reasons.push(`budget $${a.budget} below $${s.min_budget}`); }
if (a.timeline_weeks != null) { if (a.timeline_weeks <= s.max_timeline_weeks) { score += 20; reasons.push(`timeline ${a.timeline_weeks} wk`); } else reasons.push(`timeline ${a.timeline_weeks} wk is later than ${s.max_timeline_weeks}`); }
if (a.decision_maker === true) { score += 20; reasons.push('decision maker'); } else if (a.decision_maker === false) reasons.push('not the decision maker (invite them to the call)');
lead.score = score; lead.reasons = reasons;

// --- slots
const zone = s.timezone;
const fmt = (iso) => DateTime.fromISO(iso).setZone(zone).toFormat("ccc LLL d, h:mm a") + ` (${DateTime.fromISO(iso).setZone(zone).toFormat('ZZZZ')})`;
const overlaps = (st, en, b) => st < DateTime.fromISO(b.end).plus({ minutes: s.buffer_minutes }) && en > DateTime.fromISO(b.start).minus({ minutes: s.buffer_minutes });
for (const [k, h] of Object.entries(db.holds)) if (DateTime.fromISO(h.until) < now) delete db.holds[k]; // expired holds
function findSlots() {
  // Bookings and other leads' holds block time just like calendar events (buffer included)
  const asRange = (k) => ({ start: k, end: DateTime.fromISO(k).plus({ minutes: s.meeting_minutes }).toISO() });
  const blocked = [...x.busy, ...Object.keys(db.bookings).map(asRange),
    ...Object.entries(db.holds).filter(([, h]) => h.lead_id !== lead.id).map(([k]) => asRange(k))];
  const earliest = now.plus({ hours: s.min_notice_hours });
  const out = [];
  for (let d = 0; d <= s.horizon_days && out.length < 3; d++) {
    const day = now.setZone(zone).startOf('day').plus({ days: d });
    const hrs = s.hours[day.toFormat('ccc').toLowerCase()];
    if (!hrs) continue;
    for (let t = day.set({ hour: hrs.open }); t.plus({ minutes: s.meeting_minutes }) <= day.set({ hour: hrs.close }); t = t.plus({ minutes: 30 })) {
      const en = t.plus({ minutes: s.meeting_minutes });
      if (t < earliest) continue;
      const key = t.toUTC().toISO({ suppressMilliseconds: true });
      if (blocked.some((b) => overlaps(t, en, b))) continue;
      out.push(key);
      break; // one slot per day, so the options are spread out
    }
  }
  return out.map((start, i) => ({ id: `S${i + 1}`, start, label: fmt(start) }));
}
function hold(slots) {
  for (const [k, h] of Object.entries(db.holds)) if (h.lead_id === lead.id) delete db.holds[k];
  for (const sl of slots) db.holds[sl.start] = { lead_id: lead.id, until: now.plus({ minutes: s.hold_minutes }).toISO() };
}

// --- what did they pick?
function pickSlot() {
  if (!lead.offered.length) return null;
  if (x.slot_id) return lead.offered.find((o) => o.id === x.slot_id) ?? 'invalid';
  const t = x.text.toLowerCase();
  const n = t.match(/^\s*(?:option\s*|slot\s*|s)?([1-3])\b/) ?? (x.slot_hint ? [null, String(x.slot_hint).replace(/\D/g, '')] : null);
  if (n && lead.offered[Number(n[1]) - 1]) return lead.offered[Number(n[1]) - 1];
  const hit = lead.offered.filter((o) => { const dt = DateTime.fromISO(o.start).setZone(zone);
    return t.includes(dt.toFormat('cccc').toLowerCase()) || t.includes(dt.toFormat('ccc').toLowerCase() + ' ') || t.includes(dt.toFormat('h:mm a').toLowerCase()) || t.includes(dt.toFormat('ha').toLowerCase()); });
  return hit.length === 1 ? hit[0] : null;
}

const order = ['need', 'budget', 'timeline_weeks', 'decision_maker'];
const Q = {
  need: `Thanks${first !== 'there' ? ', ' + first : ''}! What would you like help with: ${s.services.map((v) => v.name).join(', ')}?`,
  budget: `Got it. Roughly what budget do you have in mind for this?`,
  timeline_weeks: `And when would you like to have this up and running?`,
  decision_maker: `Last one: are you the person who decides on this, or should someone else join the call?`,
};
let action, reply, slots = [], booking = null;

if (x.wants_human) {
  lead.status = 'handoff'; action = 'handoff';
  reply = `Of course. I've passed your details to ${s.owner_name}, who will get back to you within one business day.`;
} else if (lead.status === 'booked') {
  action = 'already_booked'; reply = `You're booked for ${lead.booking.label}. See you then! Reply here if you need to change it.`;
} else if (lead.status === 'offered' && (x.which === 'book' || pickSlot())) {
  const sl = pickSlot();
  if (!sl || sl === 'invalid') { action = 'error'; reply = 'That time is no longer available.'; x.code = 409; }
  else if (db.bookings[sl.start] && db.bookings[sl.start] !== lead.id) { action = 'error'; reply = `Sorry, ${sl.label} was just taken. Here are the other times: ${lead.offered.filter((o) => o.id !== sl.id).map((o) => o.id + ': ' + o.label).join(' · ')}`; x.code = 409; }
  else { action = 'book'; booking = sl; reply = `Booked: ${sl.label}. You'll get a ${x.calendar_mode === 'internal' ? 'confirmation email' : 'calendar invite'} at ${lead.email}.`; }
} else {
  const missing = order.find((k) => a[k] === undefined || a[k] === null);
  if (a.budget != null && a.budget < s.min_budget) {
    lead.status = 'not_a_fit'; action = 'close';
    reply = `Thanks for the details. Our projects usually start at $${s.min_budget}, so we may not be the best fit right now. These free resources might help: ${s.resources_url}`;
  } else if (a.timeline_weeks != null && a.timeline_weeks > s.max_timeline_weeks) {
    lead.status = 'nurture'; action = 'close'; lead.follow_up_on = now.plus({ weeks: Math.max(1, a.timeline_weeks - s.max_timeline_weeks) }).toISODate();
    reply = `Thanks! Since you're planning a bit further out, I'll check back in closer to the time. Meanwhile, here are some resources: ${s.resources_url}`;
  } else if (missing) {
    lead.asked = missing; action = 'ask'; reply = Q[missing];
  } else if (x.calendar_error) {
    lead.status = 'qualified'; action = 'manual_scheduling';
    reply = `Great, you're a good fit! ${s.owner_name} will email you a few times for a ${s.meeting_minutes}-minute call shortly.`;
  } else {
    slots = findSlots();
    if (!slots.length) { lead.status = 'qualified'; action = 'manual_scheduling'; reply = `Great, you're a good fit! Our calendar is full this week; ${s.owner_name} will email you some times.`; }
    else {
      lead.status = 'offered'; lead.offered = slots; hold(slots); action = 'offer';
      reply = `Great, you're a good fit! Pick a time for a ${s.meeting_minutes}-minute call with ${s.owner_name}:\n${slots.map((o) => `${o.id.slice(1)}. ${o.label}`).join('\n')}\nReply with 1, 2 or 3.`;
    }
  }
}
lead.messages.push({ from: 'bot', text: reply, at: x.now });
const summary = `${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''} · score ${score}: ${reasons.join('; ')}`;
return [{ json: {
  ok: action !== 'error', code: x.code ?? 200, lead_id: lead.id, action, reply, status: lead.status, score, reasons, answers: a,
  slots, booking, calendar_mode: x.calendar_mode, busy_source: x.busy_source, calendar_error: x.calendar_error,
  extraction_source: x.extraction_source, ai_error: x.ai_error, email: lead.email, name: lead.name, summary,
  alert_owner: ['handoff', 'manual_scheduling', 'book'].includes(action),
  event: booking ? { start: booking.start, end: DateTime.fromISO(booking.start).plus({ minutes: s.meeting_minutes }).toUTC().toISO(),
    summary: `Intro call: ${lead.name || lead.email}${lead.company ? ' (' + lead.company + ')' : ''}`, description: `${summary}\n\nConversation:\n${lead.messages.slice(-8).map((m) => m.from + ': ' + m.text).join('\n')}` } : null,
} }];