Files
Agentic-AI/src/tools/crm/_shared.js
T

186 lines
7.6 KiB
JavaScript

// ============================================
// 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,
};
});
}