import { AuvikClientConfig, AuvikDevice, AuvikDeviceResponse, AuvikTenant, AuvikTenantResponse, } from '../types/auvik'; export class AuvikClient { private config: AuvikClientConfig; private requestCount: number = 0; private requestTimestamps: number[] = []; private readonly RATE_LIMIT = 1000; // requests per hour private readonly RATE_LIMIT_WINDOW = 3600000; // 1 hour in milliseconds constructor(config: AuvikClientConfig) { this.config = config; } /** * Get Basic Authentication headers */ private getAuthHeaders(): HeadersInit { const credentials = Buffer.from( `${this.config.apiUser}:${this.config.apiKey}` ).toString('base64'); return { Authorization: `Basic ${credentials}`, Accept: 'application/json', 'Content-Type': 'application/json', }; } /** * Check and enforce rate limiting */ private checkRateLimit(): void { const now = Date.now(); // Remove timestamps older than 1 hour this.requestTimestamps = this.requestTimestamps.filter( (timestamp) => now - timestamp < this.RATE_LIMIT_WINDOW ); if (this.requestTimestamps.length >= this.RATE_LIMIT) { console.warn( `Auvik API rate limit approaching: ${this.requestTimestamps.length}/${this.RATE_LIMIT} requests in the last hour` ); } this.requestTimestamps.push(now); this.requestCount++; } /** * Make a generic API call with error handling */ private async makeApiCall(url: string, options: RequestInit = {}): Promise { this.checkRateLimit(); try { const response = await fetch(url, { ...options, headers: { ...this.getAuthHeaders(), ...options.headers, }, }); if (!response.ok) { const errorText = await response.text(); console.error( `Auvik API error: ${response.status} ${response.statusText}`, errorText ); throw new Error( `Auvik API request failed: ${response.status} ${response.statusText}` ); } return await response.json(); } catch (error) { console.error('Auvik API call failed:', error); throw error; } } /** * Get all tenants */ async getTenants(): Promise { try { const url = `${this.config.apiUrl}/v1/tenants`; console.log('Fetching Auvik tenants from:', url); const response = await this.makeApiCall(url); const tenants = response.data.map((item) => ({ id: item.id, domainPrefix: item.attributes.domainPrefix, tenantType: item.attributes.tenantType as 'multiClient' | 'client', parentId: item.relationships?.parent?.data?.id, })); console.log(`Fetched ${tenants.length} Auvik tenants`); return tenants; } catch (error) { console.error('Failed to fetch Auvik tenants:', error); return []; } } /** * Get all devices (requires tenant filtering) */ async getAllDevices(): Promise { try { // Fetch all tenants first const tenants = await this.getTenants(); if (tenants.length === 0) { console.warn('No Auvik tenants found'); return []; } // Fetch devices for all tenants const allDevices: AuvikDevice[] = []; for (const tenant of tenants) { const devices = await this.getDevicesByTenant(tenant.id); allDevices.push(...devices); } console.log(`Fetched total of ${allDevices.length} Auvik devices across all tenants`); return allDevices; } catch (error) { console.error('Failed to fetch all Auvik devices:', error); return []; } } /** * Get devices filtered by tenant ID */ async getDevicesByTenant(tenantId: string): Promise { try { const allDevices: AuvikDevice[] = []; let nextUrl: string | null = `${this.config.apiUrl}/v1/inventory/device/info?tenants=${tenantId}&page[first]=100`; console.log(`Fetching Auvik devices for tenant ${tenantId}`); // Paginate through all results while (nextUrl) { const response: AuvikDeviceResponse = await this.makeApiCall(nextUrl); const devices = response.data.map((item) => this.transformDevice(item, tenantId)); allDevices.push(...devices); // Check if there's a next page nextUrl = response.links?.next || null; if (nextUrl) { console.log(`Fetching next page for tenant ${tenantId} (${allDevices.length} devices so far)`); } } console.log(`Fetched total of ${allDevices.length} devices for tenant ${tenantId}`); return allDevices; } catch (error) { console.error(`Failed to fetch devices for tenant ${tenantId}:`, error); return []; } } /** * Transform Auvik API device response to AuvikDevice interface */ private transformDevice(item: AuvikDeviceResponse['data'][0], tenantId: string): AuvikDevice { return { id: item.id, deviceName: item.attributes.deviceName, serialNumber: item.attributes.serialNumber, macAddresses: [], // MAC addresses would need to be fetched from device details ipAddresses: item.attributes.ipAddresses || [], deviceType: item.attributes.deviceType, manufacturer: item.attributes.vendorName, model: item.attributes.makeModel, makeModel: item.attributes.makeModel, vendorName: item.attributes.vendorName, firmwareVersion: item.attributes.firmwareVersion, softwareVersion: item.attributes.softwareVersion, onlineStatus: this.normalizeOnlineStatus(item.attributes.onlineStatus), lastSeenTime: item.attributes.lastSeenTime, uptime: undefined, // Would need to be calculated from lastSeenTime tenantId: tenantId, tenantName: item.relationships?.tenant?.data?.attributes?.domainPrefix, description: item.attributes.description, }; } /** * Normalize online status to expected values */ private normalizeOnlineStatus(status: string): 'online' | 'offline' | 'unknown' { const normalized = status.toLowerCase(); if (normalized === 'online') return 'online'; if (normalized === 'offline') return 'offline'; return 'unknown'; } /** * Find tenant by company ID using database mapping */ async findTenantByCompanyId(companyId: number): Promise { try { // Query database directly for mapping const { Pool } = require('pg'); const pool = new Pool({ host: process.env.POSTGRES_HOST, port: parseInt(process.env.POSTGRES_PORT || '5432'), database: process.env.POSTGRES_DB, user: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, }); const result = await pool.query( 'SELECT auvik_tenant_id FROM auvik_tenant_mappings WHERE autotask_company_id = $1', [companyId] ); await pool.end(); if (result.rows.length > 0) { const auvikTenantId = result.rows[0].auvik_tenant_id; const tenants = await this.getTenants(); const tenant = tenants.find(t => t.id === auvikTenantId); if (tenant) { console.log(`Found tenant via mapping: ${tenant.domainPrefix} for company ID: ${companyId}`); return tenant; } } return null; } catch (error) { console.error('Failed to find tenant by company ID:', error); return null; } } /** * Normalize company name for matching by removing common suffixes and special characters */ private normalizeCompanyName(name: string): string { return name .toLowerCase() .trim() // Remove common legal suffixes .replace(/,?\s*(inc\.?|llc\.?|ltd\.?|corp\.?|corporation|company|co\.?|limited|l\.?l\.?c\.?|incorporated)$/i, '') // Remove commas and other punctuation .replace(/[,\.]/g, '') // Replace multiple spaces with single space .replace(/\s+/g, ' ') .trim(); } /** * Find tenant by name (case-insensitive, fuzzy match) * This is a fallback when no mapping exists */ async findTenantByName(companyName: string): Promise { try { const tenants = await this.getTenants(); const normalizedCompanyName = this.normalizeCompanyName(companyName); console.log(`Searching for Auvik tenant matching company: "${companyName}"`); console.log(`Normalized company name: "${normalizedCompanyName}"`); console.log(`Available tenants: ${tenants.map(t => t.domainPrefix).join(', ')}`); // Try exact match first (normalized) let match = tenants.find( (t) => this.normalizeCompanyName(t.domainPrefix) === normalizedCompanyName ); if (match) { console.log(`Found exact tenant match: ${match.domainPrefix} for company: ${companyName}`); return match; } // Try exact match on original (case-insensitive) match = tenants.find( (t) => t.domainPrefix.toLowerCase() === companyName.toLowerCase().trim() ); if (match) { console.log(`Found exact tenant match (original): ${match.domainPrefix} for company: ${companyName}`); return match; } // Try fuzzy match (contains) with normalized names // Find all potential matches and pick the best one (longest match) const potentialMatches = tenants.filter((t) => { const normalizedTenant = this.normalizeCompanyName(t.domainPrefix); return normalizedTenant.includes(normalizedCompanyName) || normalizedCompanyName.includes(normalizedTenant); }); if (potentialMatches.length > 0) { // Sort by length of normalized tenant name (descending) to prefer more specific matches match = potentialMatches.sort((a, b) => { const aNorm = this.normalizeCompanyName(a.domainPrefix); const bNorm = this.normalizeCompanyName(b.domainPrefix); return bNorm.length - aNorm.length; })[0]; console.log(`Found fuzzy tenant match: ${match.domainPrefix} for company: ${companyName}`); if (potentialMatches.length > 1) { console.log(`Other potential matches: ${potentialMatches.slice(1).map(t => t.domainPrefix).join(', ')}`); } return match; } console.log(`No tenant match found for company: ${companyName}`); console.log(`Tried to match "${normalizedCompanyName}" against: ${tenants.map(t => this.normalizeCompanyName(t.domainPrefix)).join(', ')}`); return null; } catch (error) { console.error('Failed to find tenant by name:', error); return null; } } }