464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
/**
|
|
* Sync Helper Functions
|
|
* Utility functions for sync operations including dependency ordering
|
|
*/
|
|
|
|
import { EntityType, ENTITY_DEPENDENCIES } from '../types/sync';
|
|
|
|
/**
|
|
* Get entities in dependency order (parents before children)
|
|
* @param entities List of entities to sync
|
|
* @returns Ordered list of entities respecting dependencies
|
|
*/
|
|
export function getEntitySyncOrder(entities: EntityType[]): EntityType[] {
|
|
const ordered: EntityType[] = [];
|
|
const visited = new Set<EntityType>();
|
|
const visiting = new Set<EntityType>();
|
|
|
|
function visit(entity: EntityType) {
|
|
if (visited.has(entity)) return;
|
|
if (visiting.has(entity)) {
|
|
throw new Error(`Circular dependency detected for entity: ${entity}`);
|
|
}
|
|
|
|
visiting.add(entity);
|
|
|
|
// Visit dependencies first
|
|
const dependencies = ENTITY_DEPENDENCIES[entity] || [];
|
|
for (const dep of dependencies) {
|
|
if (entities.includes(dep)) {
|
|
visit(dep);
|
|
}
|
|
}
|
|
|
|
visiting.delete(entity);
|
|
visited.add(entity);
|
|
ordered.push(entity);
|
|
}
|
|
|
|
// Visit all entities
|
|
for (const entity of entities) {
|
|
visit(entity);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Get all entities in default sync order
|
|
* @returns All entities in dependency order
|
|
*/
|
|
export function getAllEntitiesInOrder(): EntityType[] {
|
|
return getEntitySyncOrder([
|
|
EntityType.COMPANIES,
|
|
EntityType.RESOURCES,
|
|
EntityType.STATUSES,
|
|
EntityType.ISSUE_TYPES,
|
|
EntityType.SUB_ISSUE_TYPES,
|
|
EntityType.WORK_TYPES,
|
|
EntityType.QUEUES,
|
|
EntityType.PRIORITIES,
|
|
EntityType.TICKET_CATEGORIES,
|
|
EntityType.CONTACTS,
|
|
EntityType.PROJECTS,
|
|
EntityType.TICKETS,
|
|
EntityType.TASKS,
|
|
EntityType.CONFIGURATION_ITEMS,
|
|
EntityType.CONTRACTS,
|
|
EntityType.BILLING_ITEMS,
|
|
EntityType.TIME_ENTRIES,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get table name for entity type
|
|
* @param entity Entity type
|
|
* @returns PostgreSQL table name
|
|
*/
|
|
export function getTableName(entity: EntityType): string {
|
|
return entity;
|
|
}
|
|
|
|
/**
|
|
* Get Autotask API entity name
|
|
* @param entity Entity type
|
|
* @returns Autotask API entity name (PascalCase)
|
|
*/
|
|
export function getAutotaskEntityName(entity: EntityType): string {
|
|
const mapping: Record<EntityType, string> = {
|
|
[EntityType.COMPANIES]: 'Companies',
|
|
[EntityType.TICKETS]: 'Tickets',
|
|
[EntityType.TASKS]: 'Tasks',
|
|
[EntityType.PROJECTS]: 'Projects',
|
|
[EntityType.RESOURCES]: 'Resources',
|
|
[EntityType.STATUSES]: 'Statuses',
|
|
[EntityType.ISSUE_TYPES]: 'IssueTypes',
|
|
[EntityType.SUB_ISSUE_TYPES]: 'SubIssueTypes',
|
|
[EntityType.WORK_TYPES]: 'WorkTypes',
|
|
[EntityType.QUEUES]: 'Queues',
|
|
[EntityType.PRIORITIES]: 'Priorities',
|
|
[EntityType.TICKET_CATEGORIES]: 'TicketCategories',
|
|
[EntityType.BILLING_ITEMS]: 'BillingItems',
|
|
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
|
[EntityType.CONTACTS]: 'Contacts',
|
|
[EntityType.CONTRACTS]: 'Contracts',
|
|
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
|
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
|
};
|
|
|
|
return mapping[entity] || entity;
|
|
}
|
|
|
|
/**
|
|
* Check if entity is a picklist type
|
|
* @param entity Entity type
|
|
* @returns True if entity is a picklist
|
|
*/
|
|
export function isPicklistEntity(entity: EntityType): boolean {
|
|
return [
|
|
EntityType.STATUSES,
|
|
EntityType.ISSUE_TYPES,
|
|
EntityType.SUB_ISSUE_TYPES,
|
|
EntityType.WORK_TYPES,
|
|
EntityType.QUEUES,
|
|
EntityType.PRIORITIES,
|
|
EntityType.TICKET_CATEGORIES,
|
|
].includes(entity);
|
|
}
|
|
|
|
/**
|
|
* Get field name for last modified date in Autotask
|
|
* @param entity Entity type
|
|
* @returns Field name for filtering by last modified date
|
|
*/
|
|
export function getLastModifiedField(entity: EntityType): string {
|
|
const mapping: Record<EntityType, string> = {
|
|
[EntityType.COMPANIES]: 'lastActivityDate',
|
|
[EntityType.TICKETS]: 'lastActivityDate',
|
|
[EntityType.TASKS]: 'lastActivityDateTime',
|
|
[EntityType.PROJECTS]: 'lastActivityDateTime',
|
|
[EntityType.RESOURCES]: 'lastModifiedDate',
|
|
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
|
|
[EntityType.CONTACTS]: 'lastModifiedDate',
|
|
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
|
[EntityType.BILLING_ITEMS]: 'itemDate',
|
|
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
|
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
|
[EntityType.STATUSES]: 'lastModifiedDate',
|
|
[EntityType.ISSUE_TYPES]: 'lastModifiedDate',
|
|
[EntityType.SUB_ISSUE_TYPES]: 'lastModifiedDate',
|
|
[EntityType.WORK_TYPES]: 'lastModifiedDate',
|
|
[EntityType.QUEUES]: 'lastModifiedDate',
|
|
[EntityType.PRIORITIES]: 'lastModifiedDate',
|
|
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
|
|
};
|
|
|
|
return mapping[entity] || 'lastModifiedDate';
|
|
}
|
|
|
|
/**
|
|
* Get active status field name for entity
|
|
* @param entity Entity type
|
|
* @returns Field name for active status
|
|
*/
|
|
export function getActiveField(entity: EntityType): string | null {
|
|
const mapping: Record<EntityType, string | null> = {
|
|
[EntityType.COMPANIES]: 'isActive',
|
|
[EntityType.TICKETS]: null, // Use status field instead
|
|
[EntityType.TASKS]: null, // Use status field instead
|
|
[EntityType.PROJECTS]: null, // Use status field instead
|
|
[EntityType.RESOURCES]: 'isActive',
|
|
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
|
|
[EntityType.CONTACTS]: 'isActive',
|
|
[EntityType.CONTRACTS]: null, // Use status field instead
|
|
[EntityType.BILLING_ITEMS]: null,
|
|
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
|
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
|
|
[EntityType.STATUSES]: 'isActive',
|
|
[EntityType.ISSUE_TYPES]: 'isActive',
|
|
[EntityType.SUB_ISSUE_TYPES]: 'isActive',
|
|
[EntityType.WORK_TYPES]: 'isActive',
|
|
[EntityType.QUEUES]: 'isActive',
|
|
[EntityType.PRIORITIES]: 'isActive',
|
|
[EntityType.TICKET_CATEGORIES]: 'isActive',
|
|
};
|
|
|
|
return mapping[entity] || null;
|
|
}
|
|
|
|
/**
|
|
* Build Autotask query filter for incremental sync
|
|
* @param entity Entity type
|
|
* @param lastSyncTime Last successful sync timestamp
|
|
* @returns Query filter array
|
|
*/
|
|
export function buildIncrementalFilter(
|
|
entity: EntityType,
|
|
lastSyncTime: Date
|
|
): Array<{ field: string; op: string; value: any }> {
|
|
const lastModifiedField = getLastModifiedField(entity);
|
|
|
|
return [
|
|
{
|
|
field: lastModifiedField,
|
|
op: 'gte',
|
|
value: lastSyncTime.toISOString(),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build Autotask query filter for active records only
|
|
* @param entity Entity type
|
|
* @returns Query filter array or null if no active field
|
|
*/
|
|
export function buildActiveFilter(
|
|
entity: EntityType
|
|
): Array<{ field: string; op: string; value: any }> | null {
|
|
const activeField = getActiveField(entity);
|
|
|
|
if (!activeField) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
{
|
|
field: activeField,
|
|
op: 'eq',
|
|
value: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build date range filter for entities to limit sync to recent records
|
|
* @param entity Entity type
|
|
* @param yearsBack Number of years to look back (default: 2)
|
|
* @returns Query filter array or null if entity doesn't support date filtering
|
|
*/
|
|
export function buildDateRangeFilter(
|
|
entity: EntityType,
|
|
yearsBack: number = 2
|
|
): Array<{ field: string; op: string; value: any }> | null {
|
|
// Only apply date range filters to time-based entities
|
|
// Note: TIME_ENTRIES removed because Autotask API doesn't support date filtering on TimeEntry
|
|
const timeBasedEntities = [
|
|
EntityType.TICKETS,
|
|
EntityType.TASKS,
|
|
];
|
|
|
|
if (!timeBasedEntities.includes(entity)) {
|
|
return null;
|
|
}
|
|
|
|
// Define which entities should have date range filters and which field to use
|
|
const dateFieldMapping: Record<EntityType, string | null> = {
|
|
[EntityType.TICKETS]: 'createDate',
|
|
[EntityType.TASKS]: 'createDateTime',
|
|
[EntityType.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering
|
|
[EntityType.PROJECTS]: 'startDateTime',
|
|
[EntityType.BILLING_ITEMS]: 'itemDate',
|
|
[EntityType.CONTRACTS]: 'startDate', // Contracts use startDate
|
|
[EntityType.COMPANIES]: null,
|
|
[EntityType.RESOURCES]: null,
|
|
[EntityType.CONTACTS]: null,
|
|
[EntityType.CONFIGURATION_ITEMS]: null,
|
|
[EntityType.TICKET_NOTES]: null,
|
|
[EntityType.STATUSES]: null,
|
|
[EntityType.ISSUE_TYPES]: null,
|
|
[EntityType.SUB_ISSUE_TYPES]: null,
|
|
[EntityType.WORK_TYPES]: null,
|
|
[EntityType.QUEUES]: null,
|
|
[EntityType.PRIORITIES]: null,
|
|
[EntityType.TICKET_CATEGORIES]: null,
|
|
};
|
|
|
|
const dateField = dateFieldMapping[entity];
|
|
|
|
if (!dateField) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate date from X years ago
|
|
// Convert years to milliseconds for accurate calculation (including fractional years)
|
|
const cutoffDate = new Date();
|
|
const millisecondsPerYear = 365.25 * 24 * 60 * 60 * 1000; // Account for leap years
|
|
const millisecondsBack = yearsBack * millisecondsPerYear;
|
|
cutoffDate.setTime(cutoffDate.getTime() - millisecondsBack);
|
|
|
|
console.log(`Date range filter: ${dateField} >= ${cutoffDate.toISOString()} (${yearsBack} years back)`);
|
|
|
|
return [
|
|
{
|
|
field: dateField,
|
|
op: 'gte',
|
|
value: cutoffDate.toISOString(),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build special filter for contracts (requires status filter)
|
|
* @returns Query filter array for active contracts
|
|
*/
|
|
export function buildContractsFilter(): Array<{ field: string; op: string; value: any }> {
|
|
// Contracts API requires a filter. Use status = 1 for Active contracts
|
|
// Status values: 1 = Active, others are inactive/expired
|
|
return [
|
|
{
|
|
field: 'status',
|
|
op: 'eq',
|
|
value: 1,
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build special filter for projects (requires status filter)
|
|
* @returns Query filter array for active projects
|
|
*/
|
|
export function buildProjectsFilter(): Array<{ field: string; op: string; value: any }> {
|
|
// Projects API requires a filter. Use status = 1 for New/Active projects
|
|
// Status values: 1 = New, others include Complete, Cancelled, etc.
|
|
// To get all active projects, we should filter for status NOT equal to Complete (5)
|
|
return [
|
|
{
|
|
field: 'status',
|
|
op: 'noteq',
|
|
value: 5, // 5 = Complete
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build special filter for time entries (requires filter)
|
|
* @param yearsBack Number of years to look back (default: 2)
|
|
* @returns Query filter array for time entries
|
|
*/
|
|
export function buildTimeEntriesFilter(yearsBack: number = 2): Array<{ field: string; op: string; value: any }> {
|
|
// TimeEntries API requires a filter. Use dateWorked to limit the range
|
|
// Calculate date from X years ago
|
|
const cutoffDate = new Date();
|
|
const millisecondsPerYear = 365.25 * 24 * 60 * 60 * 1000;
|
|
const millisecondsBack = yearsBack * millisecondsPerYear;
|
|
cutoffDate.setTime(cutoffDate.getTime() - millisecondsBack);
|
|
|
|
console.log(`TimeEntries filter: dateWorked >= ${cutoffDate.toISOString()} (${yearsBack} years back)`);
|
|
|
|
return [
|
|
{
|
|
field: 'dateWorked',
|
|
op: 'gte',
|
|
value: cutoffDate.toISOString(),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build special filter for billing items (requires filter)
|
|
* @param yearsBack Number of years to look back (default: 2)
|
|
* @returns Query filter array for billing items
|
|
*/
|
|
export function buildBillingItemsFilter(yearsBack: number = 2): Array<{ field: string; op: string; value: any }> {
|
|
// BillingItems API requires a filter. Use itemDate to limit the range
|
|
// Calculate date from X years ago
|
|
const cutoffDate = new Date();
|
|
const millisecondsPerYear = 365.25 * 24 * 60 * 60 * 1000;
|
|
const millisecondsBack = yearsBack * millisecondsPerYear;
|
|
cutoffDate.setTime(cutoffDate.getTime() - millisecondsBack);
|
|
|
|
console.log(`BillingItems filter: itemDate >= ${cutoffDate.toISOString()} (${yearsBack} years back)`);
|
|
|
|
return [
|
|
{
|
|
field: 'itemDate',
|
|
op: 'gte',
|
|
value: cutoffDate.toISOString(),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calculate estimated sync duration based on record count
|
|
* @param recordCount Number of records to sync
|
|
* @param rateLimit Requests per second
|
|
* @param pageSize Records per page
|
|
* @returns Estimated duration in milliseconds
|
|
*/
|
|
export function estimateSyncDuration(
|
|
recordCount: number,
|
|
rateLimit: number = 10,
|
|
pageSize: number = 500
|
|
): number {
|
|
const totalPages = Math.ceil(recordCount / pageSize);
|
|
const secondsNeeded = totalPages / rateLimit;
|
|
const processingOverhead = recordCount * 0.001; // 1ms per record for processing
|
|
|
|
return (secondsNeeded * 1000) + processingOverhead;
|
|
}
|
|
|
|
/**
|
|
* Format sync duration for display
|
|
* @param milliseconds Duration in milliseconds
|
|
* @returns Formatted duration string
|
|
*/
|
|
export function formatDuration(milliseconds: number): string {
|
|
const seconds = Math.floor(milliseconds / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
} else {
|
|
return `${seconds}s`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate unique sync ID
|
|
* @returns Unique sync identifier
|
|
*/
|
|
export function generateSyncId(): string {
|
|
return `sync_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Validate entity type
|
|
* @param entity Entity type string
|
|
* @returns True if valid entity type
|
|
*/
|
|
export function isValidEntityType(entity: string): entity is EntityType {
|
|
return Object.values(EntityType).includes(entity as EntityType);
|
|
}
|
|
|
|
/**
|
|
* Get entity display name
|
|
* @param entity Entity type
|
|
* @returns Human-readable entity name
|
|
*/
|
|
export function getEntityDisplayName(entity: EntityType): string {
|
|
const mapping: Record<EntityType, string> = {
|
|
[EntityType.COMPANIES]: 'Companies',
|
|
[EntityType.TICKETS]: 'Tickets',
|
|
[EntityType.TASKS]: 'Tasks',
|
|
[EntityType.PROJECTS]: 'Projects',
|
|
[EntityType.RESOURCES]: 'Resources',
|
|
[EntityType.STATUSES]: 'Statuses',
|
|
[EntityType.ISSUE_TYPES]: 'Issue Types',
|
|
[EntityType.SUB_ISSUE_TYPES]: 'Sub-Issue Types',
|
|
[EntityType.WORK_TYPES]: 'Work Types',
|
|
[EntityType.QUEUES]: 'Queues',
|
|
[EntityType.PRIORITIES]: 'Priorities',
|
|
[EntityType.TICKET_CATEGORIES]: 'Ticket Categories',
|
|
[EntityType.BILLING_ITEMS]: 'Billing Items',
|
|
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
|
[EntityType.CONTACTS]: 'Contacts',
|
|
[EntityType.CONTRACTS]: 'Contracts',
|
|
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
|
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
|
};
|
|
|
|
return mapping[entity] || entity;
|
|
}
|