/** * IT Glue API Client * Auth: x-api-key header * Base: https://api.itglue.com * Format: application/vnd.api+json (JSON:API) */ export interface ITGlueConfig { apiKey: string; baseUrl?: string; } export interface ITGlueOrganization { id: string; name: string; shortName: string | null; organizationTypeId: number | null; organizationTypeName: string | null; organizationStatusId: number | null; organizationStatusName: string | null; psaIntegration: string | null; syncActive: boolean; primary: boolean; createdAt: string; updatedAt: string; } export interface ITGlueFlexibleAsset { id: string; organizationId: number; organizationName: string; flexibleAssetTypeId: number; flexibleAssetTypeName: string; name: string; traits: Record; createdAt: string; updatedAt: string; } export interface ITGlueConfiguration { id: string; organizationId: number; organizationName: string; name: string; hostname: string | null; primaryIp: string | null; macAddress: string | null; serialNumber: string | null; assetTag: string | null; configurationTypeId: number | null; configurationTypeName: string | null; configurationStatusId: number | null; configurationStatusName: string | null; manufacturerId: number | null; manufacturerName: string | null; modelId: number | null; modelName: string | null; operatingSystemId: number | null; operatingSystemName: string | null; notes: string | null; purchasedAt: string | null; createdAt: string; updatedAt: string; } export interface ITGluePassword { id: string; organizationId: number; organizationName: string; name: string; username: string | null; password: string | null; url: string | null; notes: string | null; passwordCategoryId: number | null; passwordCategoryName: string | null; createdAt: string; updatedAt: string; } export interface ITGlueContact { id: string; organizationId: number; organizationName: string; firstName: string | null; lastName: string | null; title: string | null; contactTypeId: number | null; contactTypeName: string | null; emails: { value: string; primary: boolean; labelName: string }[]; phones: { value: string; extension: string | null; primary: boolean; labelName: string }[]; notes: string | null; createdAt: string; updatedAt: string; } export interface ITGlueFlexibleAssetType { id: string; name: string; description: string | null; icon: string | null; enabled: boolean; createdAt: string; updatedAt: string; } export interface ITGluePaginatedResponse { data: T[]; meta: { currentPage: number; nextPage: number | null; prevPage: number | null; totalPages: number; totalCount: number; }; } export class ITGlueClient { private readonly apiKey: string; private readonly baseUrl: string; private readonly DEFAULT_PAGE_SIZE = 50; constructor(config: ITGlueConfig) { this.apiKey = config.apiKey; this.baseUrl = config.baseUrl || 'https://api.itglue.com'; } private get headers(): Record { return { 'x-api-key': this.apiKey, 'Content-Type': 'application/vnd.api+json', }; } private async request(path: string, params: Record = {}): Promise { const url = new URL(`${this.baseUrl}${path}`); for (const [k, v] of Object.entries(params)) { url.searchParams.set(k, String(v)); } const res = await fetch(url.toString(), { headers: this.headers }); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`IT Glue API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`); } return res.json(); } /** * Internal PATCH helper. Used by the audit feature to update flexible assets. * Body should be a JSON:API resource object (e.g. `{ data: { type, attributes } }`). * Returns the parsed JSON response (typically `{ data: {...} }`). */ private async patch(path: string, body: unknown): Promise { const res = await fetch(`${this.baseUrl}${path}`, { method: 'PATCH', headers: this.headers, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `IT Glue PATCH ${path} → ${res.status} ${res.statusText}: ${text.slice(0, 500)}` ); } return res.json() as Promise; } private async fetchAllPages( path: string, params: Record = {}, mapper: (item: any) => T ): Promise { const results: T[] = []; let page = 1; let totalPages = 1; do { const data: any = await this.request(path, { ...params, 'page[size]': this.DEFAULT_PAGE_SIZE, 'page[number]': page, }); results.push(...(data.data || []).map(mapper)); totalPages = data.meta?.['total-pages'] ?? 1; page++; } while (page <= totalPages); return results; } /** Returns raw JSON:API data array for a single page (used by sync service) */ async getRaw(path: string, params: Record = {}): Promise { const data: any = await this.request(path, params); return data.data || []; } /** * Returns the raw JSON:API resource for a single-resource endpoint * (`{ data: {...} }`). Used for per-record refresh after writes so callers * can read the un-mapped attributes (created-at, updated-at, etc.) for a * faithful upsert. */ async getRawSingle( path: string, params: Record = {} ): Promise<{ id: string; type: string; attributes: Record } | null> { const data: any = await this.request(path, params); return data.data ?? null; } /** Returns all raw JSON:API data items across all pages (used by sync service) */ async getRawAllPages(path: string, params: Record = {}): Promise { const results: any[] = []; let page = 1; let totalPages = 1; do { const data: any = await this.request(path, { ...params, 'page[size]': this.DEFAULT_PAGE_SIZE, 'page[number]': page, }); results.push(...(data.data || [])); totalPages = data.meta?.['total-pages'] ?? 1; page++; } while (page <= totalPages); return results; } // ─── Organizations ──────────────────────────────────────────────────────── private mapOrg(item: any): ITGlueOrganization { const a = item.attributes; return { id: item.id, name: a['name'], shortName: a['short-name'] ?? null, organizationTypeId: a['organization-type-id'] ?? null, organizationTypeName: a['organization-type-name'] ?? null, organizationStatusId: a['organization-status-id'] ?? null, organizationStatusName: a['organization-status-name'] ?? null, psaIntegration: a['psa-integration'] ?? null, syncActive: a['sync-active'] ?? false, primary: a['primary'] ?? false, createdAt: a['created-at'], updatedAt: a['updated-at'], }; } async getOrganizations(filter?: { name?: string; organizationTypeId?: number }): Promise { const params: Record = {}; if (filter?.name) params['filter[name]'] = filter.name; if (filter?.organizationTypeId) params['filter[organization-type-id]'] = filter.organizationTypeId; return this.fetchAllPages('/organizations', params, this.mapOrg); } async getOrganization(id: string | number): Promise { const data: any = await this.request(`/organizations/${id}`); return this.mapOrg(data.data); } async findOrganizationByName(name: string): Promise { const data: any = await this.request('/organizations', { 'filter[name]': name, 'page[size]': 5, }); if (!data.data?.length) return null; return this.mapOrg(data.data[0]); } // ─── Flexible Assets ────────────────────────────────────────────────────── private mapFlexibleAsset(item: any): ITGlueFlexibleAsset { const a = item.attributes; return { id: item.id, organizationId: a['organization-id'], organizationName: a['organization-name'], flexibleAssetTypeId: a['flexible-asset-type-id'], flexibleAssetTypeName: a['flexible-asset-type-name'], name: a['name'], traits: a['traits'] ?? {}, createdAt: a['created-at'], updatedAt: a['updated-at'], }; } async getFlexibleAssets(params: { organizationId?: number | string; flexibleAssetTypeId?: number | string; filter?: Record; }): Promise { const p: Record = {}; if (params.organizationId) p['filter[organization-id]'] = params.organizationId; if (params.flexibleAssetTypeId) p['filter[flexible-asset-type-id]'] = params.flexibleAssetTypeId; if (params.filter) { for (const [k, v] of Object.entries(params.filter)) { p[`filter[${k}]`] = v; } } return this.fetchAllPages('/flexible_assets', p, this.mapFlexibleAsset); } async getFlexibleAsset(id: string | number): Promise { const data: any = await this.request(`/flexible_assets/${id}`); return this.mapFlexibleAsset(data.data); } /** * Update a flexible asset's traits. Sends PATCH /flexible_assets/:id with a * JSON:API body. `traits` is the merged trait map (IT Glue replaces the * trait set, so callers must include unchanged traits to preserve them; the * audit pipeline always reads then merges). * * Returns the updated asset as IT Glue returns it. */ async updateFlexibleAsset( id: string | number, traits: Record ): Promise { const body = { data: { type: 'flexible_assets', id: String(id), attributes: { traits }, }, }; const res = await this.patch<{ data: any }>(`/flexible_assets/${id}`, body); return this.mapFlexibleAsset(res.data); } /** * Re-fetch a single flexible asset from IT Glue. Thin wrapper around * getFlexibleAsset; exists so callers naming "refresh" intent stays clear * separate from "read once". */ async refreshFlexibleAsset(id: string | number): Promise { return this.getFlexibleAsset(id); } /** * Update a configuration's editable attributes. Sends PATCH /configurations/:id * with a JSON:API body. Configurations have a flat attribute set (no traits * blob), so callers pass the partial map of fields to change — IT Glue * merges into the existing record. */ async updateConfiguration( id: string | number, attributes: Record ): Promise { const body = { data: { type: 'configurations', id: String(id), attributes, }, }; const res = await this.patch<{ data: any }>(`/configurations/${id}`, body); return this.mapConfiguration(res.data); } async refreshConfiguration(id: string | number): Promise { return this.getConfiguration(id); } async getFlexibleAssetTypes(): Promise { return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({ id: item.id, name: item.attributes['name'], description: item.attributes['description'] ?? null, icon: item.attributes['icon'] ?? null, enabled: item.attributes['enabled'] ?? true, createdAt: item.attributes['created-at'], updatedAt: item.attributes['updated-at'], })); } /** * Cached, per-instance fetch of enabled flexible asset type ids. IT Glue's * /flexible_assets endpoint requires a flexibleAssetTypeId filter (otherwise * 422). This caches the type list so callers don't pay the lookup on every * call. Cache lives for the life of the process — types rarely change. */ private flexibleAssetTypesCache: Promise | null = null; private async cachedFlexibleAssetTypes(): Promise { if (!this.flexibleAssetTypesCache) { this.flexibleAssetTypesCache = this.getFlexibleAssetTypes().catch((err) => { // Re-throw next call so a transient failure isn't sticky. this.flexibleAssetTypesCache = null; throw err; }); } return this.flexibleAssetTypesCache; } /** * Get every flexible asset for a given organization across all enabled types. * IT Glue's /flexible_assets endpoint requires a per-type filter (the * `getFlexibleAssets` raw call returns 422 otherwise), so this helper * enumerates types and fans out per-type requests in parallel. Per-type * failures are tolerated so a single bad type doesn't poison the whole org. */ async getFlexibleAssetsForOrganization( organizationId: number | string ): Promise { const types = await this.cachedFlexibleAssetTypes(); const enabled = types.filter((t) => t.enabled); const results = await Promise.allSettled( enabled.map((t) => this.getFlexibleAssets({ organizationId, flexibleAssetTypeId: t.id, }) ) ); const out: ITGlueFlexibleAsset[] = []; for (const r of results) { if (r.status === 'fulfilled') out.push(...r.value); // Tolerate per-type failures — common for permission-restricted types. } return out; } // ─── Configurations ─────────────────────────────────────────────────────── private mapConfiguration(item: any): ITGlueConfiguration { const a = item.attributes; return { id: item.id, organizationId: a['organization-id'], organizationName: a['organization-name'], name: a['name'], hostname: a['hostname'] ?? null, primaryIp: a['primary-ip'] ?? null, macAddress: a['mac-address'] ?? null, serialNumber: a['serial-number'] ?? null, assetTag: a['asset-tag'] ?? null, configurationTypeId: a['configuration-type-id'] ?? null, configurationTypeName: a['configuration-type-name'] ?? null, configurationStatusId: a['configuration-status-id'] ?? null, configurationStatusName: a['configuration-status-name'] ?? null, manufacturerId: a['manufacturer-id'] ?? null, manufacturerName: a['manufacturer-name'] ?? null, modelId: a['model-id'] ?? null, modelName: a['model-name'] ?? null, operatingSystemId: a['operating-system-id'] ?? null, operatingSystemName: a['operating-system-name'] ?? null, notes: a['notes'] ?? null, purchasedAt: a['purchased-at'] ?? null, createdAt: a['created-at'], updatedAt: a['updated-at'], }; } async getConfigurations(params: { organizationId?: number | string; name?: string; hostname?: string; serialNumber?: string; } = {}): Promise { const p: Record = {}; if (params.organizationId) p['filter[organization-id]'] = params.organizationId; if (params.name) p['filter[name]'] = params.name; if (params.hostname) p['filter[hostname]'] = params.hostname; if (params.serialNumber) p['filter[serial-number]'] = params.serialNumber; return this.fetchAllPages('/configurations', p, this.mapConfiguration); } async getConfiguration(id: string | number): Promise { const data: any = await this.request(`/configurations/${id}`); return this.mapConfiguration(data.data); } // ─── Passwords ──────────────────────────────────────────────────────────── private mapPassword(item: any): ITGluePassword { const a = item.attributes; return { id: item.id, organizationId: a['organization-id'], organizationName: a['organization-name'], name: a['name'], username: a['username'] ?? null, password: a['password'] ?? null, url: a['url'] ?? null, notes: a['notes'] ?? null, passwordCategoryId: a['password-category-id'] ?? null, passwordCategoryName: a['password-category-name'] ?? null, createdAt: a['created-at'], updatedAt: a['updated-at'], }; } async getPasswords(params: { organizationId?: number | string; name?: string; } = {}): Promise { const p: Record = {}; if (params.organizationId) p['filter[organization-id]'] = params.organizationId; if (params.name) p['filter[name]'] = params.name; return this.fetchAllPages('/passwords', p, this.mapPassword); } // ─── Contacts ───────────────────────────────────────────────────────────── private mapContact(item: any): ITGlueContact { const a = item.attributes; return { id: item.id, organizationId: a['organization-id'], organizationName: a['organization-name'], firstName: a['first-name'] ?? null, lastName: a['last-name'] ?? null, title: a['title'] ?? null, contactTypeId: a['contact-type-id'] ?? null, contactTypeName: a['contact-type-name'] ?? null, emails: (a['contact-emails'] ?? []).map((e: any) => ({ value: e.value, primary: e.primary, labelName: e['label-name'], })), phones: (a['contact-phones'] ?? []).map((p: any) => ({ value: p.value, extension: p.extension ?? null, primary: p.primary, labelName: p['label-name'], })), notes: a['notes'] ?? null, createdAt: a['created-at'], updatedAt: a['updated-at'], }; } async getContacts(params: { organizationId?: number | string; name?: string; } = {}): Promise { const p: Record = {}; if (params.organizationId) p['filter[organization-id]'] = params.organizationId; if (params.name) p['filter[name]'] = params.name; return this.fetchAllPages('/contacts', p, this.mapContact); } // ─── Utility ────────────────────────────────────────────────────────────── async testConnection(): Promise<{ ok: boolean; organizationCount: number; accountName: string }> { const data: any = await this.request('/organizations', { 'page[size]': 1 }); return { ok: true, organizationCount: data.meta?.['total-count'] ?? 0, accountName: data.data?.[0]?.attributes?.name ?? 'Unknown', }; } } // Singleton let _client: ITGlueClient | null = null; export function getITGlueClient(): ITGlueClient { if (!_client) { const apiKey = process.env.ITGLUE_API_KEY; if (!apiKey) throw new Error('ITGLUE_API_KEY environment variable is not set'); _client = new ITGlueClient({ apiKey }); } return _client; } export function isITGlueConfigured(): boolean { return !!process.env.ITGLUE_API_KEY; }