timeframe-search.tsβ’9.31 kB
/**
* Timeframe search tool configuration
*/
import {
UniversalToolConfig,
TimeframeSearchParams,
TimeframeType,
UniversalResourceType,
RelativeTimeframe,
} from '@handlers/tool-configs/universal/types.js';
import { AttioRecord } from '@shared-types/attio.js';
import { safeExtractTimestamp } from '@handlers/tool-configs/shared/type-utils.js';
import { validateUniversalToolParams } from '@handlers/tool-configs/universal/schemas.js';
import { ErrorService } from '@services/ErrorService.js';
import {
formatResourceType,
handleUniversalSearch,
} from '@handlers/tool-configs/universal/shared-handlers.js';
import { getPluralResourceType } from '@handlers/tool-configs/universal/core/utils.js';
import { normalizeOperator } from '@utils/AttioFilterOperators.js';
import { mapFieldName } from '@utils/AttioFieldMapper.js';
export const searchByTimeframeConfig: UniversalToolConfig<
TimeframeSearchParams,
AttioRecord[]
> = {
name: 'records_search_by_timeframe',
handler: async (params: TimeframeSearchParams): Promise<AttioRecord[]> => {
try {
const sanitizedParams = validateUniversalToolParams(
'records_search_by_timeframe',
params
);
const {
resource_type,
timeframe_type,
start_date,
end_date,
relative_range,
invert_range,
date_field,
limit,
offset,
} = sanitizedParams;
// Process relative_range parameter if provided (Issue #475)
let processedStartDate = start_date;
let processedEndDate = end_date;
if (relative_range) {
// Import the timeframe utility to convert relative ranges
const { getRelativeTimeframeRange } = await import(
'@utils/filters/timeframe-utils.js'
);
try {
const range = getRelativeTimeframeRange(
relative_range as RelativeTimeframe
);
processedStartDate = range.startDate;
processedEndDate = range.endDate;
} catch {
throw new Error(
`Invalid relative_range '${relative_range}'. Supported options: today, yesterday, this_week, last_week, this_month, last_month, last_7_days, last_14_days, last_30_days, last_90_days`
);
}
}
// Validate that at least one date is provided (after processing relative_range)
if (!processedStartDate && !processedEndDate) {
throw new Error(
'At least one date (start_date or end_date) is required for timeframe search'
);
}
// Determine the timestamp field to filter on (Issue #475)
// Use date_field if provided, otherwise fall back to timeframe_type logic
let timestampField: string;
if (date_field) {
// Map date_field directly to proper field name
switch (date_field) {
case 'created_at':
timestampField = mapFieldName('created_at');
break;
case 'updated_at':
timestampField = mapFieldName('modified_at'); // Map updated_at to modified_at
break;
case 'modified_at':
timestampField = mapFieldName('modified_at');
break;
default:
throw new Error(`Unsupported date_field: ${date_field}`);
}
} else {
// Fallback to original timeframe_type logic
const effectiveTimeframeType = timeframe_type || TimeframeType.MODIFIED;
switch (effectiveTimeframeType) {
case TimeframeType.CREATED:
timestampField = mapFieldName('created_at');
break;
case TimeframeType.MODIFIED:
timestampField = mapFieldName('modified_at');
break;
case TimeframeType.LAST_INTERACTION:
timestampField = mapFieldName('modified_at');
break;
default:
throw new Error(
`Unsupported timeframe type: ${effectiveTimeframeType}`
);
}
}
// Build the date filter using proper Attio API v2 filter syntax
// Use normalized operators with $ prefix
const dateFilters: Record<string, unknown>[] = [];
const coerceIso = (
d?: string,
endBoundary = false
): string | undefined => {
if (!d) return undefined;
// If date-only (YYYY-MM-DD), expand to full UTC boundary
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) {
return endBoundary ? `${d}T23:59:59.999Z` : `${d}T00:00:00Z`;
}
return d;
};
const startIso = coerceIso(processedStartDate, false);
const endIso = coerceIso(processedEndDate, true);
// Handle invert_range logic (Issue #475)
if (invert_range) {
// For inverted searches, we want records that were NOT updated in the timeframe
// This means records older than the start date OR newer than the end date
if (startIso && endIso) {
// For a range inversion, we want records outside the range
// This is typically records older than the start date (before the timeframe)
dateFilters.push({
attribute: { slug: timestampField },
condition: normalizeOperator('lt'), // Less than start date
value: startIso,
});
} else if (startIso) {
// Only start date - invert to find records older than this date
dateFilters.push({
attribute: { slug: timestampField },
condition: normalizeOperator('lt'),
value: startIso,
});
} else if (endIso) {
// Only end date - invert to find records newer than this date
dateFilters.push({
attribute: { slug: timestampField },
condition: normalizeOperator('gt'),
value: endIso,
});
}
} else {
// Normal (non-inverted) logic
if (startIso) {
dateFilters.push({
attribute: { slug: timestampField },
condition: normalizeOperator('gte'), // Normalize to $gte
value: startIso,
});
}
if (endIso) {
dateFilters.push({
attribute: { slug: timestampField },
condition: normalizeOperator('lte'), // Normalize to $lte
value: endIso,
});
}
}
// Create the filter object with the expected structure (legacy compatibility)
const filters = { filters: dateFilters } as Record<string, unknown>;
// Use the universal search handler; pass timeframe params explicitly so the
// UniversalSearchService can FORCE Query API routing for date comparisons
return await handleUniversalSearch({
resource_type,
query: '',
filters,
// Force timeframe routing parameters
timeframe_attribute: timestampField,
start_date: startIso,
end_date: endIso,
date_operator: 'between',
limit: limit || 20,
offset: offset || 0,
});
} catch (error: unknown) {
throw ErrorService.createUniversalError(
'records_search_by_timeframe',
`${params.resource_type}:${params.timeframe_type || 'undefined'}`,
error
);
}
},
formatResult: (results: AttioRecord[], ...args: unknown[]) => {
const timeframeType = args[0] as TimeframeType | undefined;
const resourceType = args[1] as UniversalResourceType | undefined;
if (!Array.isArray(results)) {
return 'Found 0 records (timeframe search)\nTip: Ensure your workspace has data in the requested date range.';
}
const timeframeName = timeframeType
? timeframeType.replace(/_/g, ' ')
: 'timeframe';
const resourceCount = results.length;
const resourceTypeName = resourceType
? resourceCount === 1
? formatResourceType(resourceType)
: getPluralResourceType(resourceType)
: resourceCount === 1
? 'record'
: 'records';
return `Found ${results.length} ${resourceTypeName} by ${timeframeName}:\n${results
.map((record: Record<string, unknown>, index: number) => {
const values = record.values as Record<string, unknown>;
const name =
(values?.name as Record<string, unknown>[])?.[0]?.value ||
(values?.name as Record<string, unknown>[])?.[0]?.full_name ||
(values?.full_name as Record<string, unknown>[])?.[0]?.value ||
(values?.title as Record<string, unknown>[])?.[0]?.value ||
'Unnamed';
const recordId = record.id as Record<string, unknown>;
const id = recordId?.record_id || 'unknown';
// Try to show relevant date information
const created = safeExtractTimestamp(record.created_at);
const modified = safeExtractTimestamp(record.updated_at);
let dateInfo = '';
if (timeframeType === TimeframeType.CREATED && created !== 'unknown') {
dateInfo = ` (created: ${new Date(created).toLocaleDateString()})`;
} else if (
timeframeType === TimeframeType.MODIFIED &&
modified !== 'unknown'
) {
dateInfo = ` (modified: ${new Date(modified).toLocaleDateString()})`;
}
return `${index + 1}. ${name}${dateInfo} (ID: ${id})`;
})
.join('\n')}`;
},
};