mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +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