Skip to main content
Glama
mapping.service.ts11.4 kB
/** * Mapping Service for Autotask ID-to-Name Resolution * Provides cached lookup functionality for company IDs and resource IDs */ import { AutotaskService } from '../services/autotask.service.js'; import { Logger } from './logger.js'; export interface MappingCache { companies: Map<number, string>; resources: Map<number, string>; lastUpdated: { companies: Date | null; resources: Date | null; }; } export interface MappingResult { id: number; name: string; found: boolean; } export class MappingService { private static instance: MappingService | null = null; private static isInitializing: boolean = false; private cache: MappingCache; private autotaskService: AutotaskService; private logger: Logger; private cacheExpiryMs: number; private constructor(autotaskService: AutotaskService, logger: Logger, cacheExpiryMs: number = 30 * 60 * 1000) { // 30 minutes default this.autotaskService = autotaskService; this.logger = logger; this.cacheExpiryMs = cacheExpiryMs; this.cache = { companies: new Map<number, string>(), resources: new Map<number, string>(), lastUpdated: { companies: null, resources: null, }, }; } /** * Get singleton instance */ public static async getInstance(autotaskService: AutotaskService, logger: Logger): Promise<MappingService> { if (MappingService.instance) { return MappingService.instance; } if (MappingService.isInitializing) { // Wait for initialization to complete return new Promise((resolve) => { const checkInit = () => { if (MappingService.instance) { resolve(MappingService.instance); } else { setTimeout(checkInit, 100); } }; checkInit(); }); } MappingService.isInitializing = true; MappingService.instance = new MappingService(autotaskService, logger); try { await MappingService.instance.initializeCache(); } catch (error) { MappingService.instance = null; MappingService.isInitializing = false; throw error; } MappingService.isInitializing = false; return MappingService.instance; } /** * Initialize cache with company and resource data */ private async initializeCache(): Promise<void> { if (this.isCacheValid('companies') && this.isCacheValid('resources')) { return; } this.logger.info('Initializing mapping cache...'); await Promise.all([ this.refreshCompanyCache(), this.refreshResourceCache() ]); this.cache.lastUpdated.companies = new Date(); this.cache.lastUpdated.resources = new Date(); this.logger.info('Mapping cache initialized successfully', { companies: this.cache.companies.size, resources: this.cache.resources.size }); } /** * Check if cache is valid (not expired) */ private isCacheValid(type: 'companies' | 'resources'): boolean { const lastUpdated = this.cache.lastUpdated[type]; if (!lastUpdated) { return false; } const now = new Date(); const timeDiff = now.getTime() - lastUpdated.getTime(); return timeDiff < this.cacheExpiryMs; } /** * Refresh cache if needed (expired) */ private async refreshCacheIfNeeded(): Promise<void> { const promises: Promise<void>[] = []; if (!this.isCacheValid('companies')) { promises.push(this.refreshCompanyCache()); } if (!this.isCacheValid('resources')) { promises.push(this.refreshResourceCache()); } if (promises.length > 0) { await Promise.all(promises); } } /** * Get company name by ID with fallback lookup */ public async getCompanyName(companyId: number): Promise<string | null> { try { await this.refreshCacheIfNeeded(); // Try cache first const cachedName = this.cache.companies.get(companyId); if (cachedName) { return cachedName; } // Fallback to direct API lookup this.logger.debug(`Company ${companyId} not in cache, doing direct lookup`); const companies = await this.autotaskService.searchCompanies({ // No searchTerm needed - we'll find by ID after getting results pageSize: 0 // Get all companies to find this specific one }); const company = companies.find((c: any) => c.id === companyId); if (company && company.companyName) { // Add to cache for future use this.cache.companies.set(companyId, company.companyName); return company.companyName; } return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.warn(`Failed to get company name for ID ${companyId}: ${errorMessage}`); return null; } } /** * Get resource name by ID with fallback lookup */ public async getResourceName(resourceId: number): Promise<string | null> { try { await this.refreshCacheIfNeeded(); // Try cache first const cachedName = this.cache.resources.get(resourceId); if (cachedName) { return cachedName; } // Check if we have any resources in cache - if not, the endpoint likely isn't available if (this.cache.resources.size === 0) { this.logger.debug(`Resource ${resourceId} not found - Resources endpoint not available in this Autotask instance`); return null; // Gracefully return null instead of attempting individual lookup } // Fallback to direct API lookup (if cache just doesn't have this specific resource) this.logger.debug(`Resource ${resourceId} not in cache, attempting direct lookup`); try { const resource = await this.autotaskService.getResource(resourceId); if (resource && resource.firstName && resource.lastName) { const fullName = `${resource.firstName} ${resource.lastName}`.trim(); // Add to cache for future use this.cache.resources.set(resourceId, fullName); return fullName; } } catch (directError) { this.logger.debug(`Direct resource lookup failed for ${resourceId}:`, directError); } return null; } catch (error) { this.logger.error(`Failed to get resource name for ${resourceId}:`, error); return null; } } /** * Get multiple company names in a single call */ async getCompanyNames(companyIds: number[]): Promise<(string | null)[]> { const results = await Promise.all( companyIds.map(id => this.getCompanyName(id)) ); return results; } /** * Get multiple resource names in a single call */ async getResourceNames(resourceIds: number[]): Promise<(string | null)[]> { const results = await Promise.all( resourceIds.map(id => this.getResourceName(id)) ); return results; } /** * Refresh the company cache */ private async refreshCompanyCache(): Promise<void> { if (this.isCacheValid('companies')) { return; // Cache is still valid } try { this.logger.info('Refreshing company cache...'); // Use pagination-by-default to get ALL companies for complete accuracy const companies = await this.autotaskService.searchCompanies({ // No pageSize specified - gets ALL companies via pagination by default }); this.cache.companies.clear(); for (const company of companies) { if (company.id && company.companyName) { this.cache.companies.set(company.id, company.companyName); } } this.cache.lastUpdated.companies = new Date(); this.logger.info(`Company cache refreshed with ${this.cache.companies.size} entries (COMPLETE dataset)`); } catch (error) { this.logger.error('Failed to refresh company cache:', error); // Don't throw error - allow fallback to direct lookup } } /** * Refresh resource cache safely (handle endpoint limitations) */ private async refreshResourceCache(): Promise<void> { try { this.logger.debug('Refreshing resource cache...'); // Note: Some Autotask instances don't support resource listing via REST API // This is a known limitation - see Autotask documentation const resources = await this.autotaskService.searchResources({ pageSize: 0 }); this.cache.resources.clear(); for (const resource of resources) { if (resource.id && resource.firstName && resource.lastName) { const fullName = `${resource.firstName} ${resource.lastName}`.trim(); this.cache.resources.set(resource.id, fullName); } } this.cache.lastUpdated.resources = new Date(); this.logger.info(`Resource cache refreshed: ${this.cache.resources.size} resources`); } catch (error) { // Handle the common case where Resources endpoint returns 405 Method Not Allowed if ((error as any)?.response?.status === 405) { this.logger.warn('Resources endpoint not available (405 Method Not Allowed) - this is common in Autotask REST API. Resource name mapping will be disabled.'); this.cache.lastUpdated.resources = new Date(); // Mark as "refreshed" to prevent retry loops return; } // Handle other resource endpoint errors gracefully this.logger.error('Failed to refresh resource cache, continuing without resource names:', error); this.cache.lastUpdated.resources = new Date(); // Mark as "refreshed" to prevent retry loops } } /** * Clear all caches */ public clearCache(): void { this.cache.companies.clear(); this.cache.resources.clear(); this.cache.lastUpdated.companies = null; this.cache.lastUpdated.resources = null; this.logger.info('Mapping cache cleared'); } /** * Clear company cache only */ public clearCompanyCache(): void { this.cache.companies.clear(); this.cache.lastUpdated.companies = null; this.logger.info('Company cache cleared'); } /** * Clear resource cache only */ public clearResourceCache(): void { this.cache.resources.clear(); this.cache.lastUpdated.resources = null; this.logger.info('Resource cache cleared'); } /** * Get cache statistics */ public getCacheStats(): { companies: { count: number; lastUpdated: Date | null; isValid: boolean }; resources: { count: number; lastUpdated: Date | null; isValid: boolean }; } { return { companies: { count: this.cache.companies.size, lastUpdated: this.cache.lastUpdated.companies, isValid: this.isCacheValid('companies'), }, resources: { count: this.cache.resources.size, lastUpdated: this.cache.lastUpdated.resources, isValid: this.isCacheValid('resources'), }, }; } /** * Preload caches (useful for warming up on startup) */ async preloadCaches(): Promise<void> { this.logger.info('Preloading mapping caches...'); try { await Promise.all([ this.refreshCompanyCache(), this.refreshResourceCache(), ]); this.logger.info('Mapping caches preloaded successfully'); } catch (error) { this.logger.error('Failed to preload caches:', error); throw error; } } }

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/asachs01/autotask-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server