wulf-pulse/lib/services/autotask-client.ts
Lorentz Hinrichsen 3c3124d8c9 Restructure: rename to Pulse and move app to root
- Renamed project from PSA-Utils to Pulse
- Moved all app files from autotask-app/ to root
- Updated package.json name to 'pulse'
- Updated Docker container names to pulse-app and pulse-redis
- Updated Docker network name to pulse-network
2025-10-28 23:08:54 -04:00

433 lines
12 KiB
TypeScript

import {
AutotaskConfig,
AutotaskHeaders,
QueryParams,
ApiResponse,
ApiError,
Resource,
Ticket,
Task,
Company,
ConfigurationItem,
Attachment,
EntityField,
PicklistValue,
} from '@/lib/types/autotask';
export class AutotaskClient {
private config: AutotaskConfig;
private rateLimiter: RateLimiter;
constructor(config: AutotaskConfig) {
this.config = config;
this.rateLimiter = new RateLimiter(10); // 10 requests per second
}
private getAuthHeaders(impersonationResourceId?: number): Record<string, string> {
// Using the direct header authentication method (not Basic auth)
const headers: Record<string, string> = {
'Username': this.config.username,
'Secret': this.config.password,
'APIIntegrationcode': this.config.apiIntegrationCode,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (impersonationResourceId) {
headers.ImpersonationResourceID = impersonationResourceId.toString();
}
return headers;
}
private async makeApiCall<T>(
url: string,
options: RequestInit
): Promise<T> {
await this.rateLimiter.throttle();
try {
const response = await fetch(url, options);
const responseText = await response.text();
if (!response.ok) {
console.error(`Autotask API error: ${response.status} - ${responseText}`);
let errorMessage = `API Error: ${response.statusText}`;
try {
const errorData: ApiError = JSON.parse(responseText);
if (errorData.errors && errorData.errors.length > 0) {
errorMessage = errorData.errors.map((e) => e.message).join(', ');
} else if (errorData.message) {
errorMessage = errorData.message;
}
} catch (parseError) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
try {
return JSON.parse(responseText);
} catch (parseError) {
console.error('Failed to parse API response:', parseError);
throw new Error('Invalid JSON response from Autotask API');
}
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
private buildQueryString(params: QueryParams): string {
if (!params.filter || params.filter.length === 0) {
return '';
}
const query = { filter: params.filter };
return `?search=${encodeURIComponent(JSON.stringify(query))}`;
}
async queryEntity<T>(
entityName: string,
params: QueryParams = {}
): Promise<T[]> {
const queryString = this.buildQueryString(params);
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.items || [];
}
// Paginated query to handle large datasets
async queryEntityPaginated<T>(
entityName: string,
params: QueryParams = {},
pageSize: number = 500
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const paginatedParams = {
...params,
MaxRecords: pageSize,
// Autotask uses MaxRecords for page size
};
console.log(`Fetching ${entityName} page ${page} (max ${pageSize} records)...`);
const queryString = this.buildQueryString(paginatedParams);
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
const items = response.items || [];
allItems.push(...items);
console.log(`Fetched ${items.length} ${entityName}, total so far: ${allItems.length}`);
// If we got fewer items than pageSize, we've reached the end
hasMore = items.length === pageSize;
page++;
// Safety limit to prevent infinite loops
if (page > 100) {
console.warn(`Reached page limit (100) for ${entityName}`);
break;
}
}
console.log(`Total ${entityName} fetched: ${allItems.length}`);
return allItems;
}
async getEntityById<T>(entityName: string, id: number): Promise<T | null> {
const url = `${this.config.apiUrl}/${entityName}/${id}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.item || null;
}
async createEntity<T>(entityName: string, data: Partial<T>): Promise<T> {
const url = `${this.config.apiUrl}/${entityName}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.item) {
throw new Error('Failed to create entity');
}
return response.item;
}
async updateEntity<T>(
entityName: string,
id: number,
data: Partial<T>
): Promise<T> {
const url = `${this.config.apiUrl}/${entityName}/${id}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'PATCH',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.item) {
throw new Error('Failed to update entity');
}
return response.item;
}
// Resource-specific methods
async getResourceByEmail(email: string): Promise<Resource | null> {
const resources = await this.queryEntity<Resource>('Resources', {
filter: [{ op: 'eq', field: 'email', value: email }],
});
return resources.length > 0 ? resources[0] : null;
}
async getAllResources(): Promise<Resource[]> {
return this.queryEntity<Resource>('Resources', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
}
// Ticket-specific methods
async getOpenTicketsByResource(resourceId: number): Promise<Ticket[]> {
return this.queryEntity<Ticket>('Tickets', {
filter: [
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
],
});
}
async getTicketsByCompany(companyId: number): Promise<Ticket[]> {
return this.queryEntity<Ticket>('Tickets', {
filter: [{ op: 'eq', field: 'companyID', value: companyId }],
});
}
async createTicket(ticket: Partial<Ticket>): Promise<Ticket> {
return this.createEntity<Ticket>('Tickets', ticket);
}
async updateTicket(id: number, updates: Partial<Ticket>): Promise<Ticket> {
return this.updateEntity<Ticket>('Tickets', id, updates);
}
// Task-specific methods
async getTasksByResource(resourceId: number): Promise<Task[]> {
return this.queryEntity<Task>('Tasks', {
filter: [
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
],
});
}
async getTasksByProject(projectId: number): Promise<Task[]> {
return this.queryEntity<Task>('Tasks', {
filter: [{ op: 'eq', field: 'projectID', value: projectId }],
});
}
async createTask(task: Partial<Task>): Promise<Task> {
return this.createEntity<Task>('Tasks', task);
}
async updateTask(id: number, updates: Partial<Task>): Promise<Task> {
return this.updateEntity<Task>('Tasks', id, updates);
}
// Company-specific methods
async getAllCompanies(): Promise<Company[]> {
const companies = await this.queryEntity<Company>('Companies', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
return companies.sort((a, b) =>
a.companyName.localeCompare(b.companyName)
);
}
async getCompanyById(id: number): Promise<Company | null> {
return this.getEntityById<Company>('Companies', id);
}
// Configuration Item methods
async getConfigurationItemsByCompany(companyId: number): Promise<ConfigurationItem[]> {
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [
{ op: 'eq', field: 'companyID', value: companyId },
{ op: 'eq', field: 'isActive', value: true }
],
});
}
async getAllConfigurationItems(): Promise<ConfigurationItem[]> {
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
}
async getConfigurationItemById(id: number): Promise<ConfigurationItem | null> {
// Use query instead of direct GET as ConfigurationItems might not support direct ID access
const items = await this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [{ op: 'eq', field: 'id', value: id }],
});
return items.length > 0 ? items[0] : null;
}
async createConfigurationItem(item: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
return this.createEntity<ConfigurationItem>('ConfigurationItems', item);
}
async updateConfigurationItem(id: number, updates: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
return this.updateEntity<ConfigurationItem>('ConfigurationItems', id, updates);
}
// Picklist methods
async getPicklistValues(
entityName: string,
fieldName: string
): Promise<Record<string | number, string>> {
const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`;
const response = await this.makeApiCall<{ fields: EntityField[] }>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
const field = response.fields.find((f) => f.name === fieldName);
if (field && field.picklistValues) {
const picklistMap: Record<string | number, string> = {};
field.picklistValues.forEach((item) => {
picklistMap[item.value] = item.label;
});
return picklistMap;
}
return {};
}
async getTicketStatusPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tickets', 'status');
}
async getTicketPriorityPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tickets', 'priority');
}
async getTaskStatusPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tasks', 'status');
}
// Attachment methods
async uploadAttachment(
entityName: string,
entityId: number,
fileBuffer: Buffer,
fileName: string,
impersonatorEmail?: string
): Promise<Attachment> {
let impersonationResourceId: number | undefined;
if (impersonatorEmail) {
const resource = await this.getResourceByEmail(impersonatorEmail);
if (resource) {
impersonationResourceId = resource.id;
}
}
const base64Data = fileBuffer.toString('base64');
const payload: Partial<Attachment> = {
id: 0,
attachmentType: 'FILE_ATTACHMENT',
fullPath: fileName,
title: fileName,
publish: 1, // All Autotask Users
data: base64Data,
};
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
method: 'POST',
headers: this.getAuthHeaders(impersonationResourceId),
body: JSON.stringify(payload),
});
if (!response.item) {
throw new Error('Failed to upload attachment');
}
return response.item;
}
async getAttachments(
entityName: string,
entityId: number
): Promise<Attachment[]> {
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.items || [];
}
}
// Rate Limiter class
class RateLimiter {
private maxRequestsPerSecond: number;
private requestTimes: number[];
constructor(maxRequestsPerSecond = 10) {
this.maxRequestsPerSecond = maxRequestsPerSecond;
this.requestTimes = [];
}
async throttle(): Promise<void> {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Remove old request times
this.requestTimes = this.requestTimes.filter((t) => t > oneSecondAgo);
// If at limit, wait
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
this.requestTimes.push(Date.now());
}
}