mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: CSV parsing, phone normalization, and fuzzy column matching utility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
143
src/lib/csv-utils.ts
Normal file
143
src/lib/csv-utils.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
export type CSVRow = Record<string, string>;
|
||||||
|
|
||||||
|
export type CSVParseResult = {
|
||||||
|
headers: string[];
|
||||||
|
rows: CSVRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCSV = (text: string): CSVParseResult => {
|
||||||
|
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
if (lines.length === 0) return { headers: [], rows: [] };
|
||||||
|
|
||||||
|
const parseLine = (line: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = parseLine(lines[0]);
|
||||||
|
const rows = lines.slice(1).map(line => {
|
||||||
|
const values = parseLine(line);
|
||||||
|
const row: CSVRow = {};
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
row[header] = values[i] ?? '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePhone = (raw: string): string => {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
|
||||||
|
return stripped.slice(-10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeadFieldMapping = {
|
||||||
|
csvHeader: string;
|
||||||
|
leadField: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_FIELDS = [
|
||||||
|
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
|
||||||
|
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
|
||||||
|
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
|
||||||
|
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
|
||||||
|
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
|
||||||
|
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
|
||||||
|
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
|
||||||
|
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
|
||||||
|
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
|
||||||
|
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
|
||||||
|
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
|
||||||
|
const used = new Set<string>();
|
||||||
|
|
||||||
|
return csvHeaders.map(header => {
|
||||||
|
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
|
||||||
|
let bestMatch: string | null = null;
|
||||||
|
|
||||||
|
for (const field of LEAD_FIELDS) {
|
||||||
|
if (used.has(field.field)) continue;
|
||||||
|
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
|
||||||
|
bestMatch = field.field;
|
||||||
|
used.add(field.field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvHeader: header,
|
||||||
|
leadField: bestMatch,
|
||||||
|
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildLeadPayload = (
|
||||||
|
row: CSVRow,
|
||||||
|
mapping: LeadFieldMapping[],
|
||||||
|
campaignId: string,
|
||||||
|
patientId: string | null,
|
||||||
|
platform: string | null,
|
||||||
|
) => {
|
||||||
|
const getValue = (field: string): string => {
|
||||||
|
const entry = mapping.find(m => m.leadField === field);
|
||||||
|
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstName = getValue('contactName.firstName') || 'Unknown';
|
||||||
|
const lastName = getValue('contactName.lastName');
|
||||||
|
const phone = normalizePhone(getValue('contactPhone'));
|
||||||
|
|
||||||
|
if (!phone || phone.length < 10) return null;
|
||||||
|
|
||||||
|
const sourceMap: Record<string, string> = {
|
||||||
|
FACEBOOK: 'FACEBOOK_AD',
|
||||||
|
GOOGLE: 'GOOGLE_AD',
|
||||||
|
INSTAGRAM: 'INSTAGRAM',
|
||||||
|
MANUAL: 'OTHER',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
|
||||||
|
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
|
||||||
|
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
|
||||||
|
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
|
||||||
|
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
|
||||||
|
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
|
||||||
|
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
|
||||||
|
source: sourceMap[platform ?? ''] ?? 'OTHER',
|
||||||
|
status: 'NEW',
|
||||||
|
campaignId,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LEAD_FIELDS };
|
||||||
Reference in New Issue
Block a user