- Created company_classifications table to store Autotask classification icons - Added getClassificationIcons() method to AutotaskClient - Created /api/sync/classifications endpoint (GET/POST) - Updated kiosk settings UI to dynamically load classifications - Added 'Sync from Autotask' button to pull latest classifications - Removed hardcoded classification list - Display classification name and description in checkboxes - Allow excluding any classification synced from Autotask
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
import {
|
|
AutotaskConfig,
|
|
AutotaskHeaders,
|
|
QueryParams,
|
|
ApiResponse,
|
|
ApiError,
|
|
Resource,
|
|
Ticket,
|
|
Task,
|
|
Company,
|
|
ConfigurationItem,
|
|
Attachment,
|
|
EntityField,
|
|
PicklistValue,
|
|
AutotaskTimeEntry,
|
|
} 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 nextPageUrl: string | null = null;
|
|
|
|
while (true) {
|
|
console.log(`Fetching ${entityName} page ${page} (max ${pageSize} records)...`);
|
|
|
|
|
|
const requestBody: any = {
|
|
MaxRecords: pageSize,
|
|
};
|
|
|
|
if (params.filter && params.filter.length > 0) {
|
|
requestBody.filter = params.filter;
|
|
console.log(`[${entityName}] Query filter:`, JSON.stringify(params.filter));
|
|
}
|
|
|
|
const url: string = nextPageUrl || `${this.config.apiUrl}/${entityName}/query`;
|
|
console.log(`[${entityName}] Request URL: ${url}`);
|
|
console.log(`[${entityName}] Request body:`, JSON.stringify(requestBody));
|
|
|
|
const response: ApiResponse<T> = await this.makeApiCall<ApiResponse<T>>(url, {
|
|
method: 'POST',
|
|
headers: this.getAuthHeaders(),
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
console.log(`[${entityName}] Response pageDetails:`, JSON.stringify(response.pageDetails));
|
|
console.log(`[${entityName}] Response items count:`, response.items?.length || 0);
|
|
|
|
const items = response.items || [];
|
|
allItems.push(...items);
|
|
|
|
console.log(`Fetched ${items.length} ${entityName}, total so far: ${allItems.length}`);
|
|
|
|
// Check if there's a next page using Autotask's pageDetails
|
|
if (response.pageDetails?.nextPageUrl) {
|
|
nextPageUrl = response.pageDetails.nextPageUrl;
|
|
page++;
|
|
} else {
|
|
// No more pages
|
|
console.log(`No more pages 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}`;
|
|
|
|
console.log(`Updating ${entityName} ${id} with data:`, data);
|
|
|
|
const response = await this.makeApiCall<any>(url, {
|
|
method: 'PUT',
|
|
headers: this.getAuthHeaders(),
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
console.log(`Update response for ${entityName} ${id}:`, response);
|
|
|
|
// Autotask returns { itemId: ... } on successful update, not { item: {...} }
|
|
// We need to fetch the updated item
|
|
if (response.itemId || response.item) {
|
|
const itemId = response.itemId || (response.item as any)?.id || id;
|
|
console.log(`Fetching updated ${entityName} ${itemId}`);
|
|
|
|
// Fetch the updated item
|
|
const updatedItem = await this.getEntityById<T>(entityName, itemId);
|
|
if (updatedItem) {
|
|
return updatedItem;
|
|
}
|
|
}
|
|
|
|
console.error(`No item or itemId in response for ${entityName} ${id}:`, response);
|
|
throw new Error('Failed to update entity - no item in response');
|
|
}
|
|
|
|
async deleteEntity(entityName: string, id: number): Promise<void> {
|
|
const url = `${this.config.apiUrl}/${entityName}/${id}`;
|
|
|
|
await this.makeApiCall<void>(url, {
|
|
method: 'DELETE',
|
|
headers: this.getAuthHeaders(),
|
|
});
|
|
}
|
|
|
|
// 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 || [];
|
|
}
|
|
|
|
// Time Entries specific methods
|
|
async getTimeEntriesByResource(resourceId: number): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [{ op: 'eq', field: 'resourceID', value: resourceId }],
|
|
});
|
|
}
|
|
|
|
async getTimeEntriesByTicket(ticketId: number): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [{ op: 'eq', field: 'ticketID', value: ticketId }],
|
|
});
|
|
}
|
|
|
|
async getTimeEntriesByTask(taskId: number): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [{ op: 'eq', field: 'taskID', value: taskId }],
|
|
});
|
|
}
|
|
|
|
async getTimeEntriesByProject(projectId: number): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [{ op: 'eq', field: 'projectID', value: projectId }],
|
|
});
|
|
}
|
|
|
|
async getTimeEntriesByCompany(companyId: number): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [{ op: 'eq', field: 'companyID', value: companyId }],
|
|
});
|
|
}
|
|
|
|
async getTimeEntriesByDateRange(startDate: Date, endDate: Date): Promise<AutotaskTimeEntry[]> {
|
|
return this.queryEntity<AutotaskTimeEntry>('TimeEntries', {
|
|
filter: [
|
|
{ op: 'gte', field: 'entryDate', value: startDate.toISOString() },
|
|
{ op: 'lte', field: 'entryDate', value: endDate.toISOString() },
|
|
],
|
|
});
|
|
}
|
|
|
|
async createTimeEntry(timeEntry: Partial<AutotaskTimeEntry>): Promise<AutotaskTimeEntry> {
|
|
return this.createEntity<AutotaskTimeEntry>('TimeEntries', timeEntry);
|
|
}
|
|
|
|
async updateTimeEntry(id: number, updates: Partial<AutotaskTimeEntry>): Promise<AutotaskTimeEntry> {
|
|
return this.updateEntity<AutotaskTimeEntry>('TimeEntries', id, updates);
|
|
}
|
|
|
|
async deleteTimeEntry(id: number): Promise<void> {
|
|
return this.deleteEntity('TimeEntries', id);
|
|
}
|
|
|
|
// Classification Icons methods
|
|
async getClassificationIcons(): Promise<any[]> {
|
|
// Autotask API endpoint for classification icons
|
|
const url = `${this.config.apiUrl}/CompanyClassificationIcons`;
|
|
|
|
const response = await this.makeApiCall<any>(url, {
|
|
method: 'GET',
|
|
headers: this.getAuthHeaders(),
|
|
});
|
|
|
|
return response.items || [];
|
|
}
|
|
|
|
async getFieldInfo(entityName: string): Promise<EntityField[]> {
|
|
const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`;
|
|
|
|
const response = await this.makeApiCall<any>(url, {
|
|
method: 'GET',
|
|
headers: this.getAuthHeaders(),
|
|
});
|
|
|
|
return response.fields || [];
|
|
}
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
}
|