// usernameUtils.js — username normalization and fuzzy matching // Usernames containing this string are admin/system accounts — skip display entirely const ADMIN_PATTERN = /admin/i; /** * Normalizes a raw Windows/AzureAD username for display. * * Returns null for: * - empty/missing values * - admin/system accounts (username contains "admin", e.g. deRAdmin, DAdmin) * * Handles: * - Domain prefix stripping: DOMAIN\user or AzureAD\KristinMcHugh * - CamelCase splitting: KristinMcHugh → Kristin Mc Hugh * - Word capitalization: johndaigle → Johndaigle, michael → Michael * * Note: all-lowercase concatenated names (johndaigle) can't be split without * external knowledge — we just capitalize the first letter. If a contact name * matches via fuzzy comparison the caller should prefer the contact's name. * * @param {string} raw — value from kabuto_information.last_user * @returns {string|null} */ export function normalizeUsername(raw) { if (!raw) return null; // Strip domain prefix (handles both DOMAIN\user and AzureAD\Name) let name = raw.includes('\\') ? raw.split('\\').pop() : raw; if (!name) return null; // Skip admin/system accounts — don't display or match these if (ADMIN_PATTERN.test(name)) return null; // If no spaces already, try CamelCase split // e.g. KristinMcHugh → Kristin Mc Hugh if (!name.includes(' ')) { name = name.replace(/([a-z])([A-Z])/g, '$1 $2'); } // Capitalize first letter of each word (leave remaining letters as-is to // preserve intentional casing like McHugh after a manual split) name = name .split(' ') .filter(Boolean) .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); return name || null; } /** * Fuzzy-matches a (possibly CamelCase or space-separated) username against a * contact's full name by stripping all spaces and comparing case-insensitively. * * "KristinMcHugh" ↔ "Kristin McHugh" → true (both → "kristinmchugh") * "johndaigle" ↔ "John Daigle" → true (both → "johndaigle") * "michael" ↔ "Michael Smith" → false (different after strip) * * @param {string} username — raw value from kabuto_information.last_user * @param {string} contactName — contact full name from Syncro * @returns {boolean} */ export function usernameFuzzyMatch(rawUsername, contactName) { if (!rawUsername || !contactName) return false; // Strip domain prefix first so DOMAIN\johndaigle compares as johndaigle const stripDomain = s => s.includes('\\') ? s.split('\\').pop() : s; const norm = s => stripDomain(s).toLowerCase().replace(/\s+/g, ''); return norm(rawUsername) === norm(contactName); } /** * Single-name matching: if the username normalizes to a single word (e.g. "michael"), * checks whether that word uniquely matches the first name of exactly one distinct * contact across all contact names in the company. If so, and the assigned contact's * first name matches, returns true. * * username="michael", contactName="Michael Smith", allContactNames=["Michael Smith"] * → true (only one "michael" first name in the company) * * username="michael", allContactNames=["Michael Smith", "Michael Jones"] * → false (ambiguous — two Michaels) * * @param {string} rawUsername — raw value from kabuto_information.last_user * @param {string} contactName — contact full name of the assigned contact * @param {string[]} allContactNames — all distinct contact names in the customer * @returns {boolean} */ export function usernameFirstNameMatch(rawUsername, contactName, allContactNames) { if (!rawUsername || !contactName || !allContactNames?.length) return false; const normalized = normalizeUsername(rawUsername); if (!normalized) return false; // Only apply to single-word usernames (multi-word is already handled by fuzzy match) if (normalized.includes(' ')) return false; const lowerUser = normalized.toLowerCase(); // Assigned contact's first name must match the username const assignedFirst = contactName.split(' ')[0].toLowerCase(); if (lowerUser !== assignedFirst) return false; // Count how many distinct contacts share that first name const distinct = new Set(allContactNames.map(n => n.toLowerCase())); const matches = [...distinct].filter(cn => cn.split(' ')[0] === lowerUser); return matches.length === 1; } /** * Initial-plus-lastname matching: handles usernames like "jgallerani" that are * constructed as first-initial + full last name. * * "jgallerani" ↔ "James Gallerani" → true (j + gallerani) * "jsmith" ↔ "John Smith" → true only if unique in company * * Requires uniqueness across the company to avoid false positives. * * @param {string} rawUsername — raw value from kabuto_information.last_user * @param {string} contactName — contact full name of the assigned contact * @param {string[]} allContactNames — all distinct contact names in the customer * @returns {boolean} */ export function usernameInitialLastNameMatch(rawUsername, contactName, allContactNames) { if (!rawUsername || !contactName || !allContactNames?.length) return false; const normalized = normalizeUsername(rawUsername); if (!normalized || normalized.includes(' ')) return false; const lowerUser = normalized.toLowerCase(); // Build pattern: first initial + last name word // e.g. "James Gallerani" → "jgallerani", "Mary Ann Smith" → "msmith" const _buildPattern = name => { const parts = name.toLowerCase().split(' ').filter(Boolean); if (parts.length < 2) return null; return parts[0][0] + parts[parts.length - 1]; }; const assignedPattern = _buildPattern(contactName); if (!assignedPattern || lowerUser !== assignedPattern) return false; // Require uniqueness: prevent matching when multiple contacts share the same pattern const distinct = new Set(allContactNames.map(n => n.toLowerCase())); const matchCount = [...distinct].filter(cn => _buildPattern(cn) === lowerUser).length; return matchCount === 1; } /** * First-name-plus-initials matching: handles usernames like "johnd" that are * constructed as firstname + first-letter(s) of last name parts. * * "johnd" ↔ "John Daigle" → true (john + d) * "johnd" ↔ "John Davis" → still true if only one "johnd" pattern in company * "johnd" ↔ "Jane Doe" → false (jane ≠ john) * * Only fires when the username is a single word and doesn't already match via * fuzzy or first-name matching. Requires uniqueness across the company. * * @param {string} rawUsername — raw value from kabuto_information.last_user * @param {string} contactName — contact full name of the assigned contact * @param {string[]} allContactNames — all distinct contact names in the customer * @returns {boolean} */ export function usernameNameInitialMatch(rawUsername, contactName, allContactNames) { if (!rawUsername || !contactName || !allContactNames?.length) return false; const normalized = normalizeUsername(rawUsername); if (!normalized || normalized.includes(' ')) return false; const lowerUser = normalized.toLowerCase(); // Build the expected pattern for the assigned contact: firstname + initials // e.g. "John Daigle" → "johnd", "Mary Ann Smith" → "maryans" const _buildPattern = name => { const parts = name.toLowerCase().split(' ').filter(Boolean); if (parts.length < 2) return null; return parts[0] + parts.slice(1).map(p => p[0]).join(''); }; const assignedPattern = _buildPattern(contactName); if (!assignedPattern || lowerUser !== assignedPattern) return false; // Check uniqueness: how many contacts produce the same pattern? const distinct = new Set(allContactNames.map(n => n.toLowerCase())); const matchCount = [...distinct].filter(cn => _buildPattern(cn) === lowerUser).length; return matchCount === 1; }