Skip to main content
Glama
UpdateValidation.ts11.8 kB
import type { AttioRecord } from '../../types/attio.js'; import { FilterValidationError } from '../../errors/api-errors.js'; import { shouldUseMockData } from '../create/index.js'; import { UniversalUtilityService } from '../UniversalUtilityService.js'; import { getCompanyDetails } from '../../objects/companies/index.js'; import { getListDetails } from '../../objects/lists.js'; import { getPersonDetails } from '../../objects/people/basic.js'; import { getObjectRecord } from '../../objects/records/index.js'; import { getTask } from '../../objects/tasks.js'; import type { UniversalResourceType } from '../../handlers/tool-configs/universal/types.js'; import { createScopedLogger } from '../../utils/logger.js'; export const UpdateValidation = { hasForbiddenContent(values: Record<string, unknown>): boolean { if (!values || typeof values !== 'object') return false; const forbidden = ['content', 'content_markdown', 'content_plaintext']; return forbidden.some((field) => field in values); }, assertNoTaskContentUpdate(recordData: Record<string, unknown>): void { if (!recordData || typeof recordData !== 'object') return; if (this.hasForbiddenContent(recordData)) { throw new FilterValidationError( 'Task content cannot be updated after creation. Content is immutable in the Attio API.' ); } }, sanitizeSpecialCharacters( data: Record<string, unknown> ): Record<string, unknown> { const sanitized: Record<string, unknown> = {}; for (const [key, value] of Object.entries(data)) { if (typeof value === 'string') { sanitized[key] = value; } else if (Array.isArray(value)) { sanitized[key] = value.map((item) => typeof item === 'string' ? item : item ); } else if (value && typeof value === 'object') { sanitized[key] = this.sanitizeSpecialCharacters( value as Record<string, unknown> ); } else { sanitized[key] = value; } } return sanitized; }, async verifyFieldPersistence( resourceType: UniversalResourceType, recordId: string, expectedUpdates: Record<string, unknown> ): Promise<{ verified: boolean; discrepancies: string[]; warnings: string[]; }> { const result = { verified: true, discrepancies: [] as string[], warnings: [] as string[], }; if (shouldUseMockData() || process.env.SKIP_FIELD_VERIFICATION === 'true') { result.warnings.push( 'Field persistence verification skipped in test environment' ); return result; } try { const verificationRecord = await this.fetchRecordForVerification( resourceType, recordId ); if (!verificationRecord) { result.verified = false; result.discrepancies.push( 'Unable to fetch record for field persistence verification' ); return result; } for (const [fieldName, expectedValue] of Object.entries( expectedUpdates )) { if ( ['created_at', 'updated_at', 'id', 'workspace_id'].includes(fieldName) ) continue; const actualValue = verificationRecord.values?.[fieldName]; const comparisonResult = this.compareFieldValues( fieldName, expectedValue, actualValue ); if (!comparisonResult.matches) { result.verified = false; result.discrepancies.push( `Field "${fieldName}" persistence mismatch: expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}` ); } else if (comparisonResult.warning) { result.warnings.push(comparisonResult.warning); } } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); result.warnings.push( `Field persistence verification failed: ${errorMessage}` ); createScopedLogger( 'update/UpdateValidation', 'verifyFieldPersistence' ).error('Field persistence verification error', error); } return result; }, async fetchRecordForVerification( resourceType: UniversalResourceType, recordId: string ): Promise<AttioRecord | null> { try { switch (resourceType) { case 'companies' as unknown as UniversalResourceType: return (await getCompanyDetails(recordId)) as unknown as AttioRecord; case 'people' as unknown as UniversalResourceType: return (await getPersonDetails(recordId)) as unknown as AttioRecord; case 'lists' as unknown as UniversalResourceType: { const list = await getListDetails(recordId); return { id: { record_id: list.id.list_id, list_id: list.id.list_id }, values: { name: (list as Record<string, unknown>).name || (list as Record<string, unknown>).title, description: (list as Record<string, unknown>).description, parent_object: (list as Record<string, unknown>).object_slug || (list as Record<string, unknown>).parent_object, api_slug: (list as Record<string, unknown>).api_slug, workspace_id: (list as Record<string, unknown>).workspace_id, workspace_member_access: (list as Record<string, unknown>) .workspace_member_access, created_at: (list as Record<string, unknown>).created_at, }, } as unknown as AttioRecord; } case 'tasks' as unknown as UniversalResourceType: { const task = await getTask(recordId); return UniversalUtilityService.convertTaskToRecord(task); } case 'deals' as unknown as UniversalResourceType: return await getObjectRecord('deals', recordId); case 'records' as unknown as UniversalResourceType: return await getObjectRecord('records', recordId); default: createScopedLogger( 'update/UpdateValidation', 'fetchRecordForVerification' ).warn( `No verification method available for resource type: ${resourceType}` ); return null; } } catch (error: unknown) { createScopedLogger( 'update/UpdateValidation', 'fetchRecordForVerification' ).error( `Failed to fetch ${resourceType} record ${recordId} for verification`, error ); return null; } }, /** * Check if a field name represents a status field (like deal stages) */ isStatusField(fieldName: string): boolean { return fieldName === 'stage' || fieldName.includes('stage'); }, /** * Handle null or undefined values */ handleNullValues( expectedValue: unknown, actualValue: unknown ): { matches: boolean } | null { if (expectedValue === null || expectedValue === undefined) { return { matches: actualValue === null || actualValue === undefined }; } if (actualValue === null || actualValue === undefined) { return { matches: false }; } return null; // Continue with other comparisons }, /** * Unwrap array responses from Attio API * Handles both status fields and regular value fields */ unwrapArrayValue(fieldName: string, actualValue: unknown): unknown { if (!Array.isArray(actualValue) || actualValue.length === 0) { return actualValue; } const firstItem = actualValue[0] as Record<string, unknown>; // Handle status fields (like deal stages) - they use 'status' property if (this.isStatusField(fieldName) && firstItem?.status !== undefined) { return actualValue.length === 1 ? firstItem.status : (actualValue as Record<string, unknown>[]).map( (v: Record<string, unknown>) => v.status ); } // Handle regular value fields if (firstItem?.value !== undefined) { return actualValue.length === 1 ? firstItem.value : (actualValue as Record<string, unknown>[]).map( (v: Record<string, unknown>) => v.value ); } return actualValue; }, /** * Compare array values using set comparison */ compareArrayValues( expectedValue: unknown[], actualValue: unknown ): { matches: boolean } { if (!Array.isArray(actualValue)) { return { matches: false }; } const expectedSet = new Set(expectedValue.map((v) => String(v))); const actualSet = new Set((actualValue as unknown[]).map((v) => String(v))); return { matches: expectedSet.size === actualSet.size && Array.from(expectedSet).every((v) => actualSet.has(v)), }; }, /** * Compare string values with case-insensitive fallback */ compareStringValues( fieldName: string, expectedValue: string, actualValue: unknown ): { matches: boolean; warning?: string } { const actualStr = String(actualValue); const matches = expectedValue === actualStr; if (!matches && expectedValue.toLowerCase() === actualStr.toLowerCase()) { return { matches: true, warning: `Field "${fieldName}" case mismatch: expected "${expectedValue}", got "${actualStr}"`, }; } return { matches }; }, /** * Compare status objects (like deal stages) */ compareStatusObjects( fieldName: string, expectedValue: Record<string, unknown>, actualValue: unknown ): { matches: boolean } | null { if (!this.isStatusField(fieldName) || !('status' in expectedValue)) { return null; // Not a status object comparison } const expectedStatus = expectedValue.status; const actualStr = String(actualValue); return { matches: String(expectedStatus) === actualStr }; }, /** * Compare primitive values (number, boolean, string) */ comparePrimitiveValues( expectedValue: unknown, actualValue: unknown ): { matches: boolean } { if (typeof expectedValue === 'number') { const actualNum = Number(actualValue); return { matches: !isNaN(actualNum) && expectedValue === actualNum }; } if (typeof expectedValue === 'boolean') { const actualBool = Boolean(actualValue); return { matches: expectedValue === actualBool }; } // String fallback return { matches: String(expectedValue) === String(actualValue) }; }, compareFieldValues( fieldName: string, expectedValue: unknown, actualValue: unknown ): { matches: boolean; warning?: string } { // Handle null/undefined values const nullCheck = this.handleNullValues(expectedValue, actualValue); if (nullCheck) return nullCheck; // Unwrap array responses from Attio API const unwrappedActual = this.unwrapArrayValue(fieldName, actualValue); // Handle array comparisons if (Array.isArray(expectedValue)) { return this.compareArrayValues(expectedValue, unwrappedActual); } // Handle string comparisons with case handling if (typeof expectedValue === 'string') { return this.compareStringValues( fieldName, expectedValue, unwrappedActual ); } // Handle status objects (like deal stages) if ( typeof expectedValue === 'object' && expectedValue !== null && !Array.isArray(expectedValue) ) { const statusComparison = this.compareStatusObjects( fieldName, expectedValue as Record<string, unknown>, unwrappedActual ); if (statusComparison) return statusComparison; } // Handle primitive values (number, boolean, string fallback) return this.comparePrimitiveValues(expectedValue, unwrappedActual); }, };

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