Skip to main content
Glama
data-normalizers.ts11.6 kB
/** * Data Normalization Utilities * * Pure data transformation functions extracted from AttioCreateService * for better separation of concerns and reusability. * * These functions handle: * - Company domain normalization * - Person name and email normalization * - Task format conversion * - Email schema transformations */ import type { AttioRecord, JsonObject } from '@shared-types/attio.js'; /** * Normalizes company input data, particularly domain handling * * Converts single domain to domains array, handles domain objects with .value property * * @param input - Raw company input data * @returns Normalized company data with domains as string array * * @example * ```typescript * // Input: { name: "Corp", domain: "corp.com" } * // Output: { name: "Corp", domains: ["corp.com"] } * * // Input: { name: "Corp", domains: [{value: "corp.com"}, {value: "corp.io"}] } * // Output: { name: "Corp", domains: ["corp.com", "corp.io"] } * ``` */ export function normalizeCompanyValues(input: JsonObject): JsonObject { const normalizedCompany: JsonObject = { ...input }; const rawDomain = input.domain as string | undefined; const rawDomains = input.domains as unknown; const rawWebsite = input.website as string | undefined; if (rawDomains) { if (Array.isArray(rawDomains)) { normalizedCompany.domains = rawDomains.map((d: unknown) => typeof d === 'string' ? d : ((d as JsonObject)?.domain ?? (d as JsonObject)?.value ?? String(d)) ); } else { normalizedCompany.domains = [ typeof rawDomains === 'string' ? rawDomains : ((rawDomains as JsonObject)?.domain ?? (rawDomains as JsonObject)?.value ?? String(rawDomains)), ]; } } else if (rawDomain) { normalizedCompany.domains = [String(rawDomain)]; delete normalizedCompany.domain; } // If website is provided, derive domain from URL and merge into domains array. // Then remove website to avoid unsupported attribute errors in Attio API. if (rawWebsite && typeof rawWebsite === 'string') { try { const url = new URL( rawWebsite.includes('://') ? rawWebsite : `https://${rawWebsite}` ); let host = url.hostname.toLowerCase(); if (host.startsWith('www.')) host = host.slice(4); const domains: string[] = Array.isArray(normalizedCompany.domains) ? (normalizedCompany.domains as string[]) : []; if (host && !domains.includes(host)) { domains.push(host); normalizedCompany.domains = domains; } } catch { // Ignore invalid URL formats; simply drop website } delete normalizedCompany.website; } return normalizedCompany; } /** * Normalizes person input data, particularly name and email handling * * Handles various name formats and converts to Attio's expected structure. * Normalizes emails to string arrays and ensures required fields exist. * * @param input - Raw person input data * @returns Normalized person data with proper name and email structures * * @example * ```typescript * // String name and email * const person = normalizePersonValues({ * name: "John Doe", * email: "john@example.com" * }); * * // Multiple emails * const person = normalizePersonValues({ * name: "Jane Smith", * email_addresses: ["jane@company.com", "jane.smith@company.com"] * }); * * // Complex name object * const person = normalizePersonValues({ * name: { first_name: "Bob", last_name: "Wilson" }, * email: "bob@example.com", * job_title: "Senior Engineer" * }); * ``` */ export function normalizePersonValues(input: JsonObject): JsonObject { const filteredPersonData: JsonObject = {}; // 1) Name normalization: array of personal-name objects const rawName = input.name; if (rawName) { if (typeof rawName === 'string') { const parts = rawName.trim().split(/\s+/); const first = parts.shift() || rawName; const last = parts.join(' '); const full = [first, last].filter(Boolean).join(' '); filteredPersonData.name = [ { first_name: first, ...(last ? { last_name: last } : {}), full_name: full, }, ]; } else if (Array.isArray(rawName)) { filteredPersonData.name = rawName; } else if (typeof rawName === 'object') { const obj = rawName as JsonObject; if ('first_name' in obj || 'last_name' in obj || 'full_name' in obj) { filteredPersonData.name = [obj]; } } } // 2) Emails: Attio create accepts string array; prefer plain strings const rawEmails = input.email_addresses; if (Array.isArray(rawEmails) && rawEmails.length) { const normalized = rawEmails.map((e: unknown) => e && typeof e === 'object' && e !== null && 'email_address' in e ? String((e as JsonObject).email_address) : String(e) ); filteredPersonData.email_addresses = normalized; } else if (typeof input.email === 'string') { filteredPersonData.email_addresses = [String(input.email)]; } else if (typeof rawEmails === 'string' && rawEmails) { // Handle case where email_addresses is a single string filteredPersonData.email_addresses = [String(rawEmails)]; } if (!filteredPersonData.name) { // Only derive name from email if email exists const emailAddresses = filteredPersonData.email_addresses as | string[] | undefined; if (emailAddresses && emailAddresses.length > 0) { const firstEmail = emailAddresses[0]; const local = typeof firstEmail === 'string' ? firstEmail.split('@')[0] : 'Test Person'; const parts = local .replace(/[^a-zA-Z]+/g, ' ') .trim() .split(/\s+/); const first = parts[0] || 'Test'; const last = parts.slice(1).join(' ') || 'User'; filteredPersonData.name = [ { first_name: first, last_name: last, full_name: `${first} ${last}`, }, ]; } else { // If no name and no email, throw explicit validation error throw new Error( 'missing required parameter: name (cannot be derived from email_addresses when email is also missing)' ); } } // 3) Company reference: normalize UUID string to proper record reference format if (input.company) { if (typeof input.company === 'string') { // UUID string → record reference object (NOT array - company is single-value) filteredPersonData.company = { target_record_id: input.company, target_object: 'companies', }; } else if (Array.isArray(input.company)) { // If already array, take first element filteredPersonData.company = input.company[0] || input.company; } else if (typeof input.company === 'object') { filteredPersonData.company = input.company; } } // 4) Optional professional info if (typeof input.title === 'string') { filteredPersonData.title = input.title; } if (typeof input.job_title === 'string') { filteredPersonData.job_title = input.job_title; } if (typeof input.description === 'string') { filteredPersonData.description = input.description; } return filteredPersonData; } /** * Converts task format to AttioRecord format * * Handles conversion between different task representations and ensures * compatibility with E2E tests that expect both nested values and flat fields. * * @param createdTask - Task data in various formats * @param originalInput - Original input data for context * @returns AttioRecord with both nested values and flat field compatibility */ export function convertTaskToAttioRecord( createdTask: JsonObject, // eslint-disable-next-line @typescript-eslint/no-unused-vars _originalInput: JsonObject ): AttioRecord { // Handle conversion from AttioTask to AttioRecord format if (createdTask && typeof createdTask === 'object' && 'id' in createdTask) { const task = createdTask as JsonObject; // If it's already an AttioRecord with record_id, ensure flat fields exist and return if (task.values && (task.id as JsonObject)?.record_id) { const base: AttioRecord = task as AttioRecord; return { ...base, // Provide flat field compatibility expected by E2E tests content: (base.values?.content as unknown as JsonObject[])?.[0]?.value || base.content, title: (base.values?.title as unknown as JsonObject[])?.[0]?.value || (base.values?.content as unknown as JsonObject[])?.[0]?.value || base.title, status: (base.values?.status as unknown as JsonObject[])?.[0]?.value || base.status, due_date: (base.values?.due_date as unknown as JsonObject[])?.[0]?.value || base.due_date || (task.deadline_at ? String(task.deadline_at).split('T')[0] : undefined), assignee_id: (base.values?.assignee as unknown as JsonObject[])?.[0]?.value || base.assignee_id, priority: base.priority || 'medium', } as unknown as AttioRecord; } // If it has task_id, convert to AttioRecord format if ((task.id as JsonObject)?.task_id) { const attioRecord: AttioRecord = { id: { record_id: (task.id as JsonObject).task_id as string, task_id: (task.id as JsonObject).task_id as string, object_id: 'tasks', workspace_id: ((task.id as JsonObject).workspace_id as string) || 'test-workspace', }, values: { content: task.content || undefined, title: task.content || undefined, status: task.status || undefined, due_date: task.deadline_at ? String(task.deadline_at).split('T')[0] : undefined, assignee: task.assignee || undefined, }, created_at: task.created_at, updated_at: task.updated_at, } as AttioRecord; return { ...attioRecord, content: task.content, title: task.content, status: task.status, due_date: task.deadline_at ? String(task.deadline_at).split('T')[0] : undefined, assignee_id: ((task.assignee as JsonObject)?.id as string) || (task.assignee_id as string), priority: task.priority || 'medium', } as unknown as AttioRecord; } } return createdTask as AttioRecord; } /** * Normalizes email addresses for different API schema requirements * * Converts between string array format and object format with email_address property. * Used by retry mechanisms when the API expects different email schemas. * * @param emailAddresses - Array of emails in various formats * @returns Object format with email_address property */ export function normalizeEmailsToObjectFormat( emailAddresses: unknown[] ): Record<string, string>[] { return emailAddresses.map((e: unknown) => ({ email_address: String(e), })); } /** * Normalizes email addresses from object format to string format * * Extracts email addresses from object format to plain string array. * * @param emailAddresses - Array of email objects * @returns String array of email addresses */ export function normalizeEmailsToStringFormat( emailAddresses: unknown[] ): string[] { return emailAddresses.map((e: unknown) => e && typeof e === 'object' && e !== null && 'email_address' in e ? String((e as Record<string, unknown>).email_address) : String(e) ); }

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