Skip to main content
Glama
people-normalization.tsβ€’20 kB
/** * People Input Normalization * * Handles normalization of people data to support multiple input formats. * Converts various input shapes to the standard Attio API format. */ import { SanitizedObject, InputSanitizer, } from '../../handlers/tool-configs/universal/schemas.js'; import { isValidEmail } from '../validation/email-validation.js'; import { UniversalValidationError, ErrorType, } from '../../handlers/tool-configs/universal/schemas.js'; import { EmailValidationConfig, EmailValidationMode, DEFAULT_EMAIL_VALIDATION_CONFIG, } from './email-validation-config.js'; import type { UnknownObject } from '../types/common.js'; /** * People name input formats */ export interface NameStringInput { name: string; } export interface NameObjectInput { first_name?: string; last_name?: string; full_name?: string; } export type NameInput = string | NameStringInput | NameObjectInput; /** * Email input formats */ export type EmailInput = | string | string[] | { email_address: string } | { email_addresses: string[] }; /** * Normalized people data */ export interface NormalizedPeopleData { first_name?: string; last_name?: string; full_name?: string; email_addresses?: Array<{ email_address: string; email_type?: string; }>; phone_numbers?: Array<{ phone_number: string; phone_type?: string; }>; [key: string]: unknown; } /** * Normalizes people input data to standard format */ export class PeopleDataNormalizer { /** * Normalize name input to standard format */ static normalizeName( input: unknown, options?: { includeFullName?: boolean } ): | { first_name?: string; last_name?: string; full_name?: string } | undefined { if (!input) return undefined; // Handle string input if (typeof input === 'string') { const trimmed = input.trim(); if (!trimmed) return undefined; // Try to split into first and last name const parts = trimmed.split(/\s+/); let result: { first_name?: string; last_name?: string; full_name?: string; }; if (parts.length === 1) { result = { first_name: parts[0], full_name: trimmed }; } else if (parts.length === 2) { result = { first_name: parts[0], last_name: parts[1], full_name: trimmed, }; } else { // Multiple parts - take first as first name, last as last name result = { first_name: parts[0], last_name: parts[parts.length - 1], full_name: trimmed, }; } // Filter out full_name if not explicitly requested if (!options?.includeFullName && result.full_name) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { full_name, ...filtered } = result; return filtered; } return result; } // Handle object input if (typeof input === 'object' && !Array.isArray(input)) { const inputObj = input as UnknownObject; const result: { first_name?: string; last_name?: string; full_name?: string; } = {}; // Check for name field (string) if (typeof inputObj.name === 'string') { return this.normalizeName(inputObj.name); } // Check for first_name and last_name if (inputObj.first_name || inputObj.last_name) { if (inputObj.first_name) { result.first_name = String(inputObj.first_name).trim(); } if (inputObj.last_name) { result.last_name = String(inputObj.last_name).trim(); } // Generate full_name if not provided if (!inputObj.full_name && (result.first_name || result.last_name)) { result.full_name = [result.first_name, result.last_name] .filter(Boolean) .join(' '); } } // Check for full_name if (inputObj.full_name) { result.full_name = String(inputObj.full_name).trim(); // If we don't have first/last name, try to extract from full name if (!result.first_name && !result.last_name) { const parts = result.full_name.split(/\s+/); if (parts.length >= 2) { result.first_name = parts[0]; result.last_name = parts.slice(1).join(' '); } else if (parts.length === 1) { result.first_name = parts[0]; } } } // Check for firstName/lastName (camelCase variants) if (inputObj.firstName) { result.first_name = String(inputObj.firstName).trim(); } if (inputObj.lastName) { result.last_name = String(inputObj.lastName).trim(); } if (Object.keys(result).length === 0) return undefined; // Filter out full_name if not explicitly requested if (!options?.includeFullName && result.full_name) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { full_name, ...filtered } = result; return Object.keys(filtered).length > 0 ? filtered : undefined; } return result; } return undefined; } /** * Extract email value from various input formats */ private static extractEmailValue(emailField: unknown): string | null { if (typeof emailField === 'string') { return emailField; } if (typeof emailField === 'object' && emailField && 'value' in emailField) { const value = (emailField as UnknownObject).value; if (typeof value === 'string') { return value; } } // Handle malformed inputs - don't try to convert objects, arrays, etc. to strings if (emailField === null || emailField === undefined) { return null; } if (typeof emailField === 'object' || Array.isArray(emailField)) { return null; // Don't convert objects/arrays to "[object object]" strings } if (typeof emailField === 'number' || typeof emailField === 'boolean') { return null; // Don't convert numbers/booleans to strings for email validation } return String(emailField); } /** * Validate and process a single email based on configuration */ private static validateAndProcessEmail( emailValue: string, config: EmailValidationConfig ): string | null { const normalized = InputSanitizer.normalizeEmail(emailValue); // Check if the normalized email is empty or invalid if (!normalized || !normalized.trim()) { if (config.mode === EmailValidationMode.STRICT) { throw new UniversalValidationError( `Invalid email format: "${emailValue}". Please provide a valid email address (e.g., user@example.com)`, ErrorType.USER_ERROR, { field: 'email_addresses', suggestion: 'Ensure email addresses are in the format: user@domain.com', } ); } return null; } if (!isValidEmail(normalized)) { switch (config.mode) { case EmailValidationMode.STRICT: throw new UniversalValidationError( `Invalid email format: "${normalized}". Please provide a valid email address (e.g., user@example.com)`, ErrorType.USER_ERROR, { field: 'email_addresses', suggestion: 'Ensure email addresses are in the format: user@domain.com', } ); case EmailValidationMode.WARN: config.logger?.( `WARNING: Invalid email format "${normalized}" was skipped. Consider updating to a valid format.`, 'warn' ); return null; case EmailValidationMode.LEGACY: if (config.logDeprecationWarnings) { config.logger?.( 'DEPRECATION WARNING: Invalid emails are being silently ignored. This behavior will change in a future version. Use EMAIL_VALIDATION_MODE=strict for new behavior.', 'warn' ); } return null; default: return null; } } return normalized; } /** * Normalize email input to standard format * * @param input - Email input in various formats * @param config - Email validation configuration (optional) */ static normalizeEmails( input: unknown, config: EmailValidationConfig = DEFAULT_EMAIL_VALIDATION_CONFIG ): Array<{ email_address: string; email_type?: string }> | undefined { if (input === null || input === undefined) return undefined; const emails: Array<{ email_address: string; email_type?: string }> = []; // Handle string input if (typeof input === 'string') { const validatedEmail = this.validateAndProcessEmail(input, config); if (validatedEmail) { emails.push({ email_address: validatedEmail }); } } // Handle array input else if (Array.isArray(input)) { for (const item of input) { if (typeof item === 'string') { const validatedEmail = this.validateAndProcessEmail(item, config); if (validatedEmail) { emails.push({ email_address: validatedEmail }); } } else if (typeof item === 'object' && item) { // ISSUE #518 FIX: Enhanced object email format support // Added support for multiple object formats: {email_address: "..."}, {value: "..."}, {email: "..."} // Handle objects with email_address field (official Attio API format) if (item.email_address) { const emailValue = this.extractEmailValue(item.email_address); if (emailValue) { const validatedEmail = this.validateAndProcessEmail( emailValue, config ); if (validatedEmail) { emails.push({ email_address: validatedEmail, email_type: item.email_type || item.type, }); } } } // Handle objects with value field (alternative format) else if (item.value) { const emailValue = this.extractEmailValue(item.value); if (emailValue) { const validatedEmail = this.validateAndProcessEmail( emailValue, config ); if (validatedEmail) { emails.push({ email_address: validatedEmail, email_type: item.email_type || item.type, }); } } } // Handle objects with email field (alternative format) else if (item.email) { const emailValue = this.extractEmailValue(item.email); if (emailValue) { const validatedEmail = this.validateAndProcessEmail( emailValue, config ); if (validatedEmail) { emails.push({ email_address: validatedEmail, email_type: item.email_type || item.type, }); } } } } } } // Handle object input else if (typeof input === 'object') { const inputObj = input as UnknownObject; // Check for email_address field if (inputObj.email_address) { const emailValue = this.extractEmailValue(inputObj.email_address); if (emailValue) { const validatedEmail = this.validateAndProcessEmail( emailValue, config ); if (validatedEmail) { emails.push({ email_address: validatedEmail, email_type: (inputObj.email_type as string) || (inputObj.type as string), }); } } } // Check for email_addresses field else if ( inputObj.email_addresses && Array.isArray(inputObj.email_addresses) ) { const normalized = this.normalizeEmails( inputObj.email_addresses, config ); if (normalized) { emails.push(...normalized); } } // Check for email field (singular) else if (inputObj.email) { const emailValue = this.extractEmailValue(inputObj.email); if (emailValue) { const validatedEmail = this.validateAndProcessEmail( emailValue, config ); if (validatedEmail) { emails.push({ email_address: validatedEmail }); } } } // Check for emails field (plural) else if (inputObj.emails && Array.isArray(inputObj.emails)) { const normalized = this.normalizeEmails(inputObj.emails, config); if (normalized) { emails.push(...normalized); } } } if (emails.length === 0) return undefined; // Add default email_type: "primary" for single emails without a type if (emails.length === 1 && !emails[0].email_type) { emails[0].email_type = 'primary'; } // For multiple emails, ensure first has primary if none specified if (emails.length > 1) { const hasTyped = emails.some((e) => e.email_type); if (!hasTyped) { emails[0].email_type = 'primary'; for (let i = 1; i < emails.length; i++) { emails[i].email_type = 'secondary'; } } } return emails; } /** * Normalize phone number input */ static normalizePhones( input: unknown ): Array<{ phone_number: string; phone_type?: string }> | undefined { if (!input) return undefined; const phones: Array<{ phone_number: string; phone_type?: string }> = []; // Handle string input if (typeof input === 'string') { const normalized = this.normalizePhoneNumber(input); if (normalized) { phones.push({ phone_number: normalized }); } } // Handle array input else if (Array.isArray(input)) { for (const item of input) { if (typeof item === 'string') { const normalized = this.normalizePhoneNumber(item); if (normalized) { phones.push({ phone_number: normalized }); } } else if ( typeof item === 'object' && (item.phone_number || item.number) ) { const normalized = this.normalizePhoneNumber( item.phone_number || item.number ); if (normalized) { phones.push({ phone_number: normalized, phone_type: item.phone_type || item.type, }); } } } } // Handle object input else if (typeof input === 'object') { const inputObj = input as UnknownObject; // Check various phone field names const phoneFields = [ 'phone_number', 'phone', 'phoneNumber', 'mobile', 'telephone', ]; for (const field of phoneFields) { if (inputObj[field]) { const normalized = this.normalizePhoneNumber( inputObj[field] as string ); if (normalized) { phones.push({ phone_number: normalized, phone_type: (inputObj.phone_type as string) || (inputObj.type as string), }); break; } } } // Check for phone_numbers array if (inputObj.phone_numbers || inputObj.phones) { const normalized = this.normalizePhones( inputObj.phone_numbers || inputObj.phones ); if (normalized) { phones.push(...normalized); } } } return phones.length > 0 ? phones : undefined; } /** * Normalize a single phone number */ private static normalizePhoneNumber(phone: string): string | undefined { if (!phone || typeof phone !== 'string') return undefined; // Remove all non-digit characters except + at the beginning let normalized = phone.trim(); const hasPlus = normalized.startsWith('+'); normalized = normalized.replace(/\D/g, ''); // Re-add + if it was there if (hasPlus) { normalized = '+' + normalized; } // Basic validation - should have at least 7 digits const digitCount = normalized.replace(/\D/g, '').length; if (digitCount < 7 || digitCount > 15) { return undefined; } return normalized; } /** * Normalize complete people record data * * @param data - People data to normalize * @param emailConfig - Email validation configuration (optional) */ static normalizePeopleData( data: unknown, emailConfig: EmailValidationConfig = DEFAULT_EMAIL_VALIDATION_CONFIG ): NormalizedPeopleData { if (!data || typeof data !== 'object' || Array.isArray(data)) { return {}; } const sanitized = InputSanitizer.sanitizeObject(data) as SanitizedObject; const normalized: NormalizedPeopleData = {}; // Normalize name const nameFields = [ 'name', 'first_name', 'last_name', 'full_name', 'firstName', 'lastName', ]; const hasNameField = nameFields.some((field) => field in sanitized); if (hasNameField) { const nameData = this.normalizeName(sanitized); if (nameData) { // Create name array for Attio personal-name format normalized.name = [ { first_name: nameData.first_name || '', last_name: nameData.last_name || '', full_name: nameData.full_name || [nameData.first_name, nameData.last_name] .filter(Boolean) .join(' '), }, ]; // Also add flattened fields for backward compatibility with attribute-format-helpers if (nameData.first_name) { normalized.first_name = nameData.first_name; } if (nameData.last_name) { normalized.last_name = nameData.last_name; } if (nameData.full_name) { normalized.full_name = nameData.full_name; } } } // Normalize emails const emailFields = [ 'email', 'emails', 'email_address', 'email_addresses', 'emailAddress', ]; const hasEmailField = emailFields.some((field) => field in sanitized); if (hasEmailField) { const emailData = this.normalizeEmails(sanitized, emailConfig); if (emailData) { normalized.email_addresses = emailData; } } // Normalize phones const phoneFields = [ 'phone', 'phones', 'phone_number', 'phone_numbers', 'phoneNumber', 'mobile', 'telephone', ]; const hasPhoneField = phoneFields.some((field) => field in sanitized); if (hasPhoneField) { const phoneData = this.normalizePhones(sanitized); if (phoneData) { normalized.phone_numbers = phoneData; } } // Copy over other fields that aren't being normalized const normalizedFields = new Set([ ...nameFields, ...emailFields, ...phoneFields, ]); for (const [key, value] of Object.entries(sanitized)) { if (!normalizedFields.has(key)) { normalized[key] = value; } } return normalized; } /** * Check if data needs people normalization */ static needsNormalization(data: unknown): boolean { if (!data || typeof data !== 'object' || Array.isArray(data)) { return false; } // Check for fields that indicate people data needing normalization const peopleFields = [ 'name', // String name instead of object 'email', // Singular email 'email_address', // Singular email address 'phone', // Singular phone 'phone_number', // Singular phone number 'firstName', // CamelCase variants 'lastName', 'emailAddress', 'phoneNumber', ]; return peopleFields.some((field) => field in data); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server