Skip to main content
Glama
UniversalSearchService.tsβ€’23.8 kB
/** * UniversalSearchService - Centralized record search operations * * Issue #574: Refactored to use Strategy Pattern for resource-specific search logic * Reduced from 1800+ lines to <500 lines by extracting strategies */ import { UniversalResourceType, SearchType, MatchType, SortType, } from '../handlers/tool-configs/universal/types.js'; import type { UniversalSearchParams } from '../handlers/tool-configs/universal/types.js'; import { AttioRecord } from '../types/attio.js'; import { performance } from 'perf_hooks'; import { debug, createScopedLogger, OperationType } from '../utils/logger.js'; // Import services import { ValidationService } from './ValidationService.js'; import { CachingService } from './CachingService.js'; // Import performance tracking import { enhancedPerformanceTracker } from '../middleware/performance-enhanced.js'; // Import error types for validation and proper error handling import { AuthenticationError, AuthorizationError, NetworkError, RateLimitError, ServerError, ResourceNotFoundError, createApiErrorFromAxiosError, } from '../errors/api-errors.js'; // Import resource-specific search functions import { advancedSearchCompanies } from '../objects/companies/index.js'; import { advancedSearchPeople } from '../objects/people/index.js'; import { advancedSearchDeals } from '../objects/deals/index.js'; import { searchLists } from '../objects/lists.js'; import { listObjectRecords } from '../objects/records/index.js'; import { listTasks } from '../objects/tasks.js'; import { listNotes, normalizeNoteResponse } from '../objects/notes.js'; // Import guardrails // Import Attio client for deal queries import { getLazyAttioClient } from '../api/lazy-client.js'; import * as AttioClientModule from '../api/attio-client.js'; import type { AxiosInstance } from 'axios'; // Note: Attio client resolution is centralized in getLazyAttioClient(), // which prefers mocked getAttioClient() during tests/offline. // Import factory for guard checks // Prefer the module's getAttioClient (enables Vitest mocks). Fallback to lazy client. function resolveQueryApiClient(): AxiosInstance { const mod = AttioClientModule as { getAttioClient?: () => AxiosInstance }; if (typeof mod.getAttioClient === 'function') { return mod.getAttioClient(); } return getLazyAttioClient(); } // Import new query API utilities import { createRelationshipQuery, createTimeframeQuery, createContentSearchQuery, } from '../utils/filters/index.js'; // Import timeframe utility functions for Issue #475 import { convertDateParamsToTimeframeQuery } from '../utils/filters/timeframe-utils.js'; // Import query API types import { RelationshipQuery, TimeframeQuery } from '../utils/filters/types.js'; // Import search strategies import { ISearchStrategy, CompanySearchStrategy, PeopleSearchStrategy, DealSearchStrategy, TaskSearchStrategy, ListSearchStrategy, NoteSearchStrategy, StrategyDependencies, } from './search-strategies/index.js'; import { SearchUtilities } from './search-utilities/SearchUtilities.js'; import { ensureFunctionAvailability } from './search-utilities/FunctionValidator.js'; // Dynamic imports for better error handling using extracted utility pattern const ensureAdvancedSearchCompanies = () => ensureFunctionAvailability( advancedSearchCompanies, 'advancedSearchCompanies' ); const ensureAdvancedSearchPeople = () => ensureFunctionAvailability(advancedSearchPeople, 'advancedSearchPeople'); const ensureAdvancedSearchDeals = () => ensureFunctionAvailability(advancedSearchDeals, 'advancedSearchDeals'); /** * UniversalSearchService provides centralized record search functionality */ export class UniversalSearchService { private static strategies = new Map<UniversalResourceType, ISearchStrategy>(); /** * Initialize search strategies with their dependencies */ private static async initializeStrategies(): Promise<void> { if (this.strategies.size > 0) { return; // Already initialized } // Create dependencies for strategies const companyDependencies: StrategyDependencies = { advancedSearchFunction: await ensureAdvancedSearchCompanies(), createDateFilter: SearchUtilities.createDateFilter, mergeFilters: SearchUtilities.mergeFilters, rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; const peopleDependencies: StrategyDependencies = { paginatedSearchFunction: await ensureAdvancedSearchPeople(), createDateFilter: SearchUtilities.createDateFilter, mergeFilters: SearchUtilities.mergeFilters, rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; const dealDependencies: StrategyDependencies = { advancedSearchFunction: await ensureAdvancedSearchDeals(), createDateFilter: SearchUtilities.createDateFilter, mergeFilters: SearchUtilities.mergeFilters, rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; const listDependencies: StrategyDependencies = { listFunction: (query?: string, limit?: number, offset?: number) => searchLists(query || '', limit, offset), rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; const taskDependencies: StrategyDependencies = { taskFunction: ( status?: string, assigneeId?: string, page?: number, pageSize?: number ) => listTasks(status, assigneeId, page, pageSize), rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; const noteDependencies: StrategyDependencies = { noteFunction: (query?: Record<string, unknown>) => listNotes(query || {}), rankByRelevance: SearchUtilities.rankByRelevance.bind(SearchUtilities), getFieldValue: SearchUtilities.getFieldValue.bind(SearchUtilities), }; // Initialize strategies this.strategies.set( UniversalResourceType.COMPANIES, new CompanySearchStrategy(companyDependencies) ); this.strategies.set( UniversalResourceType.PEOPLE, new PeopleSearchStrategy(peopleDependencies) ); this.strategies.set( UniversalResourceType.DEALS, new DealSearchStrategy(dealDependencies) ); this.strategies.set( UniversalResourceType.LISTS, new ListSearchStrategy(listDependencies) ); this.strategies.set( UniversalResourceType.TASKS, new TaskSearchStrategy(taskDependencies) ); this.strategies.set( UniversalResourceType.NOTES, new NoteSearchStrategy(noteDependencies) ); } /** * Universal search handler with performance tracking */ static async searchRecords( params: UniversalSearchParams ): Promise<AttioRecord[]> { const { resource_type, query, filters, limit, offset, search_type = SearchType.BASIC, fields, match_type = MatchType.PARTIAL, sort = SortType.NAME, // New TC search parameters relationship_target_type, relationship_target_id, timeframe_attribute, start_date, end_date, date_operator, content_fields, use_or_logic, // Issue #475: New date filtering parameters date_from, date_to, created_after, created_before, updated_after, updated_before, timeframe, date_field, } = params; // Start performance tracking const perfId = enhancedPerformanceTracker.startOperation( 'search-records', 'search', { resourceType: resource_type, hasQuery: !!query, hasFilters: !!(filters && Object.keys(filters).length > 0), limit, offset, searchType: search_type, hasFields: !!(fields && fields.length > 0), matchType: match_type, sortType: sort, } ); // Track validation timing const validationStart = performance.now(); // Validate pagination parameters using ValidationService ValidationService.validatePaginationParameters({ limit, offset }, perfId); // Validate filter schema for malformed advanced filters ValidationService.validateFiltersSchema(filters); enhancedPerformanceTracker.markTiming( perfId, 'validation', performance.now() - validationStart ); // Issue #475: Convert user-friendly date parameters to API format let processedTimeframeParams = { timeframe_attribute, start_date, end_date, date_operator, }; try { const dateConversion = convertDateParamsToTimeframeQuery({ date_from, date_to, created_after, created_before, updated_after, updated_before, timeframe, date_field, }); if (dateConversion) { // Use converted parameters, prioritizing user-friendly parameters processedTimeframeParams = { ...processedTimeframeParams, ...dateConversion, }; } } catch (dateError: unknown) { // Re-throw date validation errors with helpful context const errorMessage = dateError instanceof Error ? `Date parameter validation failed: ${dateError.message}` : 'Invalid date parameters provided'; throw new Error(errorMessage); } // Auto-detect timeframe searches and FORCE them to use the Query API let finalSearchType = search_type; const hasTimeframeParams = processedTimeframeParams.timeframe_attribute && (processedTimeframeParams.start_date || processedTimeframeParams.end_date); if (hasTimeframeParams) { finalSearchType = SearchType.TIMEFRAME; debug( 'UniversalSearchService', 'FORCING timeframe search to use Query API (advanced search API does not support date comparisons)', { originalSearchType: search_type, timeframe_attribute: processedTimeframeParams.timeframe_attribute, start_date: processedTimeframeParams.start_date, end_date: processedTimeframeParams.end_date, date_operator: processedTimeframeParams.date_operator, } ); } // Track API call timing const apiStart = enhancedPerformanceTracker.markApiStart(perfId); let results: AttioRecord[]; try { results = await this.performSearchByResourceType( resource_type, { query, filters, limit, offset, search_type: finalSearchType, fields, match_type, sort, // New TC search parameters relationship_target_type, relationship_target_id, // Use processed timeframe parameters (Issue #475) timeframe_attribute: processedTimeframeParams.timeframe_attribute, start_date: processedTimeframeParams.start_date, end_date: processedTimeframeParams.end_date, date_operator: processedTimeframeParams.date_operator, content_fields, use_or_logic, }, perfId, apiStart ); enhancedPerformanceTracker.markApiEnd(perfId, apiStart); enhancedPerformanceTracker.endOperation(perfId, true, undefined, 200, { recordCount: results.length, }); return results; } catch (apiError: unknown) { enhancedPerformanceTracker.markApiEnd(perfId, apiStart); const errorObj = apiError as Record<string, unknown>; const statusCode = ((errorObj?.response as Record<string, unknown>)?.status as number) || (errorObj?.statusCode as number) || 500; const errorMessage = apiError instanceof Error ? apiError.message : 'Search failed'; enhancedPerformanceTracker.endOperation( perfId, false, errorMessage, statusCode ); throw apiError; } } /** * Perform search by resource type with strategy pattern */ private static async performSearchByResourceType( resource_type: UniversalResourceType, params: { query?: string; filters?: Record<string, unknown>; limit?: number; offset?: number; search_type?: SearchType; fields?: string[]; match_type?: MatchType; sort?: SortType; // New TC search parameters relationship_target_type?: UniversalResourceType; relationship_target_id?: string; timeframe_attribute?: string; start_date?: string; end_date?: string; date_operator?: 'greater_than' | 'less_than' | 'between' | 'equals'; content_fields?: string[]; use_or_logic?: boolean; }, perfId: string, apiStart: number ): Promise<AttioRecord[]> { const { query, filters, limit, offset, search_type, fields, match_type, sort, relationship_target_type, relationship_target_id, timeframe_attribute, start_date, end_date, date_operator, content_fields, use_or_logic, } = params; // Handle new search types first (unchanged from original) switch (search_type) { case SearchType.RELATIONSHIP: if (relationship_target_type && relationship_target_id) { return this.searchByRelationship( resource_type, relationship_target_type, relationship_target_id, limit, offset ); } throw new Error( 'Relationship search requires target_type and target_id parameters' ); case SearchType.TIMEFRAME: if (timeframe_attribute) { const timeframeConfig: TimeframeQuery = { resourceType: resource_type, attribute: timeframe_attribute, startDate: start_date, endDate: end_date, operator: date_operator || 'between', }; return this.searchByTimeframe( resource_type, timeframeConfig, limit, offset ); } throw new Error( 'Timeframe search requires timeframe_attribute parameter' ); case SearchType.CONTENT: // Use new Query API if content_fields is explicitly provided if (content_fields && content_fields.length > 0) { if (!query) { throw new Error('Content search requires query parameter'); } return this.searchByContent( resource_type, query, content_fields, use_or_logic !== false, limit, offset ); } // Fall through to strategy-based content search break; } // Initialize strategies if needed await this.initializeStrategies(); // Use strategy pattern for resource-specific searches const strategy = this.strategies.get(resource_type); if (strategy) { return await strategy.search({ query, filters, limit, offset, search_type, fields, match_type, sort, timeframeParams: { timeframe_attribute, start_date, end_date, date_operator, }, }); } // Fallback for resources without strategies (RECORDS only) switch (resource_type) { case UniversalResourceType.RECORDS: return this.searchRecords_ObjectType(limit, offset, filters); default: throw new Error( `Unsupported resource type for search: ${resource_type}` ); } } // LEGACY METHODS - These remain unchanged for non-strategy resources /** * Search records using object records API with filter support */ private static async searchRecords_ObjectType( limit?: number, offset?: number, filters?: Record<string, unknown> ): Promise<AttioRecord[]> { // Handle list_membership filters - invalid UUID should return empty array if (filters?.list_membership) { const listId = String(filters.list_membership); if (!ValidationService.validateUUIDForSearch(listId)) { return []; // Return empty success for invalid UUID } createScopedLogger( 'UniversalSearchService', 'searchRecords_ObjectType', OperationType.DATA_PROCESSING ).warn('list_membership filter not yet supported in listObjectRecords'); } return await listObjectRecords('records', { pageSize: limit, page: Math.floor((offset || 0) / (limit || 10)) + 1, }); } // Query API methods remain unchanged static async searchByRelationship( sourceResourceType: UniversalResourceType, targetResourceType: UniversalResourceType, targetRecordId: string, limit?: number, offset?: number ): Promise<AttioRecord[]> { const relationshipQuery: RelationshipQuery = { sourceObjectType: sourceResourceType, targetObjectType: targetResourceType, targetAttribute: 'id', condition: 'equals', value: targetRecordId, }; const queryApiFilter = createRelationshipQuery(relationshipQuery); try { const client = resolveQueryApiClient(); const path = `/objects/${sourceResourceType}/records/query`; const requestBody = { ...queryApiFilter, limit: limit || 10, offset: offset || 0, }; const response = await client.post(path, requestBody); return response?.data?.data || []; } catch (error: unknown) { const apiError = createApiErrorFromAxiosError( error, `/objects/${sourceResourceType}/records/query`, 'POST' ); if ( apiError instanceof AuthenticationError || apiError instanceof AuthorizationError || apiError instanceof NetworkError || apiError instanceof RateLimitError || apiError instanceof ServerError ) { throw apiError; } if (apiError instanceof ResourceNotFoundError) { debug( 'UniversalSearchService', `No relationship found between ${sourceResourceType} -> ${targetResourceType}`, { targetRecordId } ); return []; } createScopedLogger( 'UniversalSearchService', 'searchByRelationship', OperationType.API_CALL ).error( `Relationship search failed for ${sourceResourceType} -> ${targetResourceType}`, error ); return []; } } static async searchByTimeframe( resourceType: UniversalResourceType, timeframeConfig: TimeframeQuery, limit?: number, offset?: number ): Promise<AttioRecord[]> { const queryApiFilter = createTimeframeQuery(timeframeConfig); try { const client = resolveQueryApiClient(); const path = `/objects/${resourceType}/records/query`; const requestBody = { ...queryApiFilter, limit: limit || 10, offset: offset || 0, }; const response = await client.post(path, requestBody); return response?.data?.data || []; } catch (error: unknown) { const apiError = createApiErrorFromAxiosError( error, `/objects/${resourceType}/records/query`, 'POST' ); if ( apiError instanceof AuthenticationError || apiError instanceof AuthorizationError || apiError instanceof NetworkError || apiError instanceof RateLimitError || apiError instanceof ServerError ) { throw apiError; } if (apiError instanceof ResourceNotFoundError) { debug( 'UniversalSearchService', `No ${resourceType} records found in specified timeframe`, { timeframeConfig } ); return []; } createScopedLogger( 'UniversalSearchService', 'searchByTimeframe', OperationType.API_CALL ).error(`Timeframe search failed for ${resourceType}`, error); return []; } } static async searchByContent( resourceType: UniversalResourceType, query: string, searchFields: string[] = [], useOrLogic: boolean = true, limit?: number, offset?: number ): Promise<AttioRecord[]> { let fields = searchFields; if (fields.length === 0) { switch (resourceType) { case UniversalResourceType.COMPANIES: fields = ['name', 'description', 'domains']; break; case UniversalResourceType.PEOPLE: fields = ['name', 'email_addresses', 'job_title']; break; default: fields = ['name']; break; } } const queryApiFilter = createContentSearchQuery(fields, query, useOrLogic); try { const client = getLazyAttioClient(); const path = `/objects/${resourceType}/records/query`; const requestBody = { ...queryApiFilter, limit: limit || 10, offset: offset || 0, }; const response = await client.post(path, requestBody); return response?.data?.data || []; } catch (error: unknown) { const apiError = createApiErrorFromAxiosError( error, `/objects/${resourceType}/records/query`, 'POST' ); if ( apiError instanceof AuthenticationError || apiError instanceof AuthorizationError || apiError instanceof NetworkError || apiError instanceof RateLimitError || apiError instanceof ServerError ) { throw apiError; } if (apiError instanceof ResourceNotFoundError) { debug( 'UniversalSearchService', `No ${resourceType} records found matching content search`, { query, fields } ); return []; } createScopedLogger( 'UniversalSearchService', 'searchByContent', OperationType.API_CALL ).error(`Content search failed for ${resourceType}`, error); return []; } } // Utility methods remain unchanged static async getSearchSuggestions(): Promise<string[]> { return []; } static async getRecordCount( resource_type: UniversalResourceType ): Promise<number> { switch (resource_type) { case UniversalResourceType.TASKS: { const cachedTasks = CachingService.getCachedTasks('tasks_cache'); return cachedTasks ? cachedTasks.length : -1; } default: return -1; } } static supportsAdvancedFiltering( resource_type: UniversalResourceType ): boolean { switch (resource_type) { case UniversalResourceType.COMPANIES: case UniversalResourceType.PEOPLE: return true; case UniversalResourceType.LISTS: case UniversalResourceType.RECORDS: case UniversalResourceType.DEALS: case UniversalResourceType.TASKS: return false; default: return false; } } static supportsQuerySearch(resource_type: UniversalResourceType): boolean { switch (resource_type) { case UniversalResourceType.COMPANIES: case UniversalResourceType.PEOPLE: case UniversalResourceType.LISTS: return true; case UniversalResourceType.RECORDS: case UniversalResourceType.DEALS: case UniversalResourceType.TASKS: return false; default: return false; } } }

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