search.ts•7.3 kB
/**
* Search functionality for companies
*
* Simplified implementation following CLAUDE.md documentation-first rule.
* Uses standard Attio API patterns instead of custom workarounds.
*/
import { getLazyAttioClient } from '../../api/lazy-client.js';
import {
searchObject,
advancedSearchObject,
ListEntryFilters,
} from '../../api/operations/index.js';
import { createScopedLogger } from '@/utils/logger.js';
import {
ResourceType,
Company,
FilterConditionType,
} from '../../types/attio.js';
import {
FilterValidationError,
FilterErrorCategory,
} from '../../errors/api-errors.js';
import { normalizeDomain } from '../../utils/domain-utils.js';
/**
* Configuration options for company search
*/
export interface CompanySearchOptions {
/** Maximum number of results to return */
maxResults?: number;
}
/**
* Searches for companies by name using standard Attio API
*
* @param query - Search query string to match against company names
* @param options - Optional search configuration
* @returns Array of matching company objects
*/
export async function searchCompanies(
query: string,
options: CompanySearchOptions = {}
): Promise<Company[]> {
if (!query || typeof query !== 'string' || !query.trim()) {
return [];
}
if (
options.maxResults !== undefined &&
(typeof options.maxResults !== 'number' ||
options.maxResults < 0 ||
!Number.isInteger(options.maxResults))
) {
throw new Error('maxResults must be a non-negative integer');
}
const results = await searchObject<Company>(ResourceType.COMPANIES, query);
// Apply maxResults limit if specified
return options.maxResults ? results.slice(0, options.maxResults) : results;
}
/**
* Searches for companies by domain using correct 'domains' field
*
* @param domain - Domain to search for
* @returns Array of matching company objects
*/
export async function searchCompaniesByDomain(
domain: string
): Promise<Company[]> {
if (!domain || typeof domain !== 'string' || !domain.trim()) {
return [];
}
const normalizedDomain = normalizeDomain(domain);
const api = getLazyAttioClient();
try {
const response = await api.post('/objects/companies/records/query', {
filter: {
domains: { $contains: normalizedDomain },
},
});
return response?.data?.data || [];
} catch (error: unknown) {
// Use structured logging with sanitized error information
const logger = createScopedLogger(
'companies-search',
'searchCompaniesByDomain'
);
logger.error(`Domain search failed for domain`, error, {
domain: normalizedDomain,
});
return [];
}
}
/**
* Performs advanced search with custom filters using standard API
*
* @param filters - List of filters to apply
* @param limit - Maximum number of results to return
* @param offset - Number of results to skip
* @returns Array of company results
*/
export async function advancedSearchCompanies(
filters: ListEntryFilters,
limit?: number,
offset?: number
): Promise<Company[]> {
// Strict validation BEFORE calling advancedSearchObject
// This ensures FilterValidationError is thrown for invalid inputs
if (!filters) {
throw new FilterValidationError(
'Filters object is required',
FilterErrorCategory.STRUCTURE
);
}
if (!('filters' in filters)) {
throw new FilterValidationError(
'Filters must include a "filters" array',
FilterErrorCategory.STRUCTURE
);
}
if (!Array.isArray(filters.filters)) {
throw new FilterValidationError(
'Filters.filters must be an array',
FilterErrorCategory.STRUCTURE
);
}
// Validate each filter condition structure
if (filters.filters && filters.filters.length > 0) {
filters.filters.forEach((filter, index) => {
if (!filter || typeof filter !== 'object') {
throw new FilterValidationError(
`Invalid condition at index ${index}: filter must be an object`,
FilterErrorCategory.STRUCTURE
);
}
if (!filter.attribute) {
throw new FilterValidationError(
`Invalid condition at index ${index}: missing attribute object`,
FilterErrorCategory.ATTRIBUTE
);
}
if (!filter.attribute.slug) {
throw new FilterValidationError(
`Invalid condition at index ${index}: missing attribute.slug property`,
FilterErrorCategory.ATTRIBUTE
);
}
if (!filter.condition) {
throw new FilterValidationError(
`Invalid condition at index ${index}: missing condition property`,
FilterErrorCategory.CONDITION
);
}
// Additional validation for unknown operators/malformed structures
if (typeof filter.condition !== 'string') {
throw new FilterValidationError(
`Invalid condition at index ${index}: condition must be a string`,
FilterErrorCategory.CONDITION
);
}
});
}
if (
limit !== undefined &&
(typeof limit !== 'number' || limit < 0 || !Number.isInteger(limit))
) {
throw new FilterValidationError(
'Limit must be a non-negative integer',
FilterErrorCategory.VALUE
);
}
if (
offset !== undefined &&
(typeof offset !== 'number' || offset < 0 || !Number.isInteger(offset))
) {
throw new FilterValidationError(
'Offset must be a non-negative integer',
FilterErrorCategory.VALUE
);
}
return await advancedSearchObject<Company>(
ResourceType.COMPANIES,
filters,
limit,
offset
);
}
/**
* Helper function to create filters for searching companies by name
*/
export function createNameFilter(
name: string,
condition: FilterConditionType = FilterConditionType.CONTAINS
): ListEntryFilters {
if (!name || typeof name !== 'string') {
throw new FilterValidationError(
'Name parameter must be a non-empty string',
FilterErrorCategory.VALUE
);
}
return {
filters: [
{
attribute: { slug: 'name' },
condition: condition,
value: name,
},
],
};
}
/**
* Helper function to create filters for searching companies by domain
*/
export function createDomainFilter(
domain: string,
condition: FilterConditionType = FilterConditionType.CONTAINS
): ListEntryFilters {
if (!domain || typeof domain !== 'string') {
throw new FilterValidationError(
'Domain parameter must be a non-empty string',
FilterErrorCategory.VALUE
);
}
const normalizedDomain = normalizeDomain(domain);
return {
filters: [
{
attribute: { slug: 'domains' },
condition: condition,
value: normalizedDomain,
},
],
};
}
/**
* Helper function to create filters for searching companies by industry
*/
export function createIndustryFilter(
industry: string,
condition: FilterConditionType = FilterConditionType.CONTAINS
): ListEntryFilters {
if (!industry || typeof industry !== 'string') {
throw new FilterValidationError(
'Industry parameter must be a non-empty string',
FilterErrorCategory.VALUE
);
}
return {
filters: [
{
attribute: { slug: 'industry' },
condition: condition,
value: industry,
},
],
};
}