433 lines
12 KiB
TypeScript
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());
|
|
}
|
|
}
|