// ============================================ // Shared query helpers for the CRM read tools. // ============================================ import { z } from 'zod'; import { EnrollmentLog } from '../../data/models/index.js'; /** Payment status that counts as a real enrolment. */ export const PAID = 'SUCCESS'; /** * Phone numbers that have actually paid, in every format the CRM stores. * * Lead.enrolled is false on every lead record — nothing in the CRM sets it — * so enrolment truth lives in `enrollmentlogs`. Those hold bare 10-digit * mobiles while leads are stored 91-prefixed, so a naive join matches nothing. * The paid set is small (tens of rows), so materialising it and matching with * $in beats a $lookup doing string surgery across 4.4k leads. * * Cached briefly: several tools in one turn otherwise repeat the same scan. */ let _paidCache = { at: 0, value: null }; export async function paidPhoneVariants(ttlMs = 60_000) { if (_paidCache.value && Date.now() - _paidCache.at < ttlMs) return _paidCache.value; const rows = await EnrollmentLog.find({ paymentStatus: PAID }, { primaryMobile: 1 }).lean(); const out = new Set(); for (const r of rows) { const d = String(r.primaryMobile || '').replace(/\D/g, ''); if (d.length < 10) continue; const ten = d.slice(-10); out.add(ten); out.add('91' + ten); } _paidCache = { at: Date.now(), value: [...out] }; return _paidCache.value; } /** Normalise to the 12-digit Indian format the CRM stores (91XXXXXXXXXX). */ export function normalizePhone(raw = '') { const digits = String(raw).replace(/\D/g, ''); if (digits.length === 10) return '91' + digits; if (digits.length === 12 && digits.startsWith('91')) return digits; if (digits.length === 13 && digits.startsWith('091')) return digits.slice(1); return digits; } /** Match either stored form — some leads predate country-code normalisation. */ export function phoneVariants(raw) { const n = normalizePhone(raw); const short = n.startsWith('91') ? n.slice(2) : n; return [...new Set([n, short, raw])].filter(Boolean); } /** * Relative date windows. The model is far more reliable picking a named window * than computing ISO timestamps, and this keeps "this month" meaning the same * thing in every tool. */ export const DATE_RANGES = [ 'today', 'yesterday', 'last_7_days', 'last_30_days', 'last_90_days', 'this_week', 'this_month', 'last_month', 'this_quarter', 'this_year', 'all_time', ]; export function resolveRange(range = 'last_30_days', now = new Date()) { const d = (x) => new Date(x); const startOfDay = (x) => { const y = d(x); y.setHours(0, 0, 0, 0); return y; }; const end = new Date(now); let start; switch (range) { case 'today': start = startOfDay(now); break; case 'yesterday': { start = startOfDay(now); start.setDate(start.getDate() - 1); const e = startOfDay(now); return { start, end: e, label: 'yesterday' }; } case 'last_7_days': start = startOfDay(now); start.setDate(start.getDate() - 7); break; case 'last_30_days': start = startOfDay(now); start.setDate(start.getDate() - 30); break; case 'last_90_days': start = startOfDay(now); start.setDate(start.getDate() - 90); break; case 'this_week': { start = startOfDay(now); start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); // Monday break; } case 'this_month': start = new Date(now.getFullYear(), now.getMonth(), 1); break; case 'last_month': { start = new Date(now.getFullYear(), now.getMonth() - 1, 1); return { start, end: new Date(now.getFullYear(), now.getMonth(), 1), label: 'last month' }; } case 'this_quarter': start = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1); break; case 'this_year': start = new Date(now.getFullYear(), 0, 1); break; case 'all_time': return { start: new Date(0), end, label: 'all time' }; default: start = startOfDay(now); start.setDate(start.getDate() - 30); } return { start, end, label: range.replace(/_/g, ' ') }; } export const dateRangeSchema = z.enum(DATE_RANGES).default('last_30_days') .describe('Relative time window for the query.'); /** Fields safe to project to the model — excludes tracking/PII noise. */ export const LEAD_SUMMARY_FIELDS = { phone_number: 1, name: 1, wa_name: 1, email: 1, lead_score: 1, lead_tag: 1, current_stage: 1, funnel_stage: 1, segment: 1, source: 1, interested_course: 1, interested_courses: 1, qualification: 1, assigned_to: 1, enrolled: 1, enrollment_date: 1, follow_up_date: 1, last_interaction: 1, first_interaction: 1, total_messages: 1, tags: 1, createdAt: 1, needs_human: 1, city: 1, }; /** Trim a Mongo doc down for the model: drop nulls and empty strings. */ export function compact(doc) { if (!doc || typeof doc !== 'object') return doc; const out = {}; for (const [k, v] of Object.entries(doc)) { if (v == null || v === '' || (Array.isArray(v) && v.length === 0)) continue; out[k] = v; } return out; } /** * Resolve a person reference — a phone number OR a name — to phone numbers. * * Conversations are keyed by phone and their `participant_name` is empty on * ~99.8% of records (4,004 of 4,014 in the live database), so a name can only * be resolved by going through the Lead collection and joining on phone. Any * conversation lookup that searched `participant_name` directly reported "no * chat found" for people who plainly had one. * * @returns {{phones: string[], matches: Array<{name:string, phone:string}>, ambiguous: boolean}} */ export async function resolvePerson(query) { const { Lead } = await import('../../data/models/index.js'); const raw = String(query || '').trim(); if (!raw) return { phones: [], matches: [], ambiguous: false }; // Enough digits to be a phone number → use it directly. if ((raw.match(/\d/g) || []).length >= 8) { return { phones: phoneVariants(raw), matches: [], ambiguous: false }; } // Otherwise treat it as a name. Escape regex metacharacters so a name like // "R. Kumar (Dev)" cannot blow up or match unintended records. const safe = [...raw].map((ch) => ('\\^$.|?*+()[]{}'.includes(ch) ? '\\' + ch : ch)).join(''); // "md salim" should still match "Md. Salim" — join words with a wildcard. const rx = new RegExp(safe.trim().split(/\s+/).join('.*'), 'i'); const leads = await Lead.find({ $or: [{ name: rx }, { wa_name: rx }] }) .select('name wa_name phone_number') .limit(10) .lean(); const matches = leads.map((l) => ({ name: l.name || l.wa_name || '(unnamed)', phone: l.phone_number })); const phones = [...new Set(leads.flatMap((l) => phoneVariants(l.phone_number)))]; return { phones, matches, ambiguous: matches.length > 1 }; } /** * Conversations rarely carry a usable `participant_name`, so attach the lead's * name by phone. Without this every inbox listing reads as a wall of numbers. */ export async function attachLeadNames(rows, phoneField = 'phone_number') { if (!rows?.length) return rows; const { Lead } = await import('../../data/models/index.js'); const phones = [...new Set(rows.flatMap((r) => phoneVariants(r[phoneField])))]; const leads = await Lead.find({ phone_number: { $in: phones } }) .select('name wa_name phone_number current_stage lead_tag') .lean(); const byPhone = new Map(); for (const l of leads) { for (const v of phoneVariants(l.phone_number)) { byPhone.set(v, l); } } return rows.map((r) => { const lead = byPhone.get(normalizePhone(r[phoneField])) || byPhone.get(r[phoneField]); return { ...r, participant_name: r.participant_name || lead?.name || lead?.wa_name || '', lead_stage: lead?.current_stage, lead_tag: lead?.lead_tag, }; }); }