/** * 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(); const visiting = new Set(); 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.PROJECT_PHASES, EntityType.TICKETS, EntityType.TICKET_NOTES, EntityType.TASKS, EntityType.CONFIGURATION_ITEMS, EntityType.CONTRACTS, EntityType.CONTRACT_SERVICES, EntityType.AUTOTASK_SERVICES, EntityType.BILLING_ITEMS, EntityType.TIME_ENTRIES, EntityType.TAG_GROUPS, EntityType.TAGS, EntityType.COMPANY_TEAMS, ]); } /** * Build filter for contract services (requires contractID filter — fetch all via contractIDs) * @returns Query filter array for active contract services */ export function buildContractServicesFilter(): Array<{ field: string; op: string; value: any }> { return [ { field: 'contractID', op: 'gt', value: 0, }, ]; } /** * 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.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.CONTRACT_SERVICES]: 'ContractServices', [EntityType.AUTOTASK_SERVICES]: 'Services', [EntityType.TIME_ENTRIES]: 'TimeEntries', [EntityType.TICKET_NOTES]: 'TicketNotes', [EntityType.TAG_GROUPS]: 'TagGroups', [EntityType.TAGS]: 'Tags', [EntityType.PROJECT_PHASES]: 'Phases', [EntityType.COMPANY_CATEGORIES]: 'CompanyCategories', [EntityType.COMPANY_TYPES]: 'CompanyTypes', [EntityType.COMPANY_TEAMS]: 'CompanyTeams', }; 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, EntityType.COMPANY_TYPES, ].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.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.CONTRACT_SERVICES]: 'lastModifiedDate', [EntityType.AUTOTASK_SERVICES]: 'lastModifiedDate', [EntityType.BILLING_ITEMS]: 'itemDate', [EntityType.TIME_ENTRIES]: 'lastModifiedDateTime', [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', [EntityType.TAG_GROUPS]: 'lastModifiedDate', [EntityType.TAGS]: 'lastModifiedDateTime', [EntityType.PROJECT_PHASES]: 'lastActivityDateTime', [EntityType.COMPANY_CATEGORIES]: 'lastModifiedDate', [EntityType.COMPANY_TYPES]: 'lastModifiedDate', [EntityType.COMPANY_TEAMS]: 'lastModifiedDate', // No date field; incremental not supported }; 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.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.CONTRACT_SERVICES]: null, [EntityType.AUTOTASK_SERVICES]: 'isActive', [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', [EntityType.TAG_GROUPS]: 'isActive', [EntityType.TAGS]: 'isActive', [EntityType.PROJECT_PHASES]: null, // No active field on phases [EntityType.COMPANY_CATEGORIES]: 'isActive', [EntityType.COMPANY_TYPES]: 'isActive', [EntityType.COMPANY_TEAMS]: null, }; 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.TICKET_NOTES, 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.TICKETS]: 'createDate', [EntityType.TASKS]: 'createDateTime', [EntityType.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering [EntityType.PROJECTS]: 'startDateTime', [EntityType.BILLING_ITEMS]: 'itemDate', [EntityType.CONTRACTS]: 'startDate', [EntityType.CONTRACT_SERVICES]: null, [EntityType.AUTOTASK_SERVICES]: null, [EntityType.COMPANIES]: null, [EntityType.RESOURCES]: null, [EntityType.CONTACTS]: null, [EntityType.CONFIGURATION_ITEMS]: null, [EntityType.TICKET_NOTES]: 'lastActivityDate', [EntityType.TAG_GROUPS]: null, [EntityType.TAGS]: 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, [EntityType.PROJECT_PHASES]: null, [EntityType.COMPANY_CATEGORIES]: null, [EntityType.COMPANY_TYPES]: null, [EntityType.COMPANY_TEAMS]: 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 filter for project phases (Phases endpoint requires a filter) * @returns Query filter array for all phases */ export function buildProjectPhasesFilter(): Array<{ field: string; op: string; value: any }> { return [ { field: 'id', op: 'gt', value: 0, }, ]; } /** * 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 lastModifiedDateTime so that edits // to existing entries (e.g. hours changed, notes updated) are picked up even // if the dateWorked is in the past or future. // We still bound it by yearsBack to avoid a full scan on first load. 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: lastModifiedDateTime >= ${cutoffDate.toISOString()} (${yearsBack} years back)`); return [ { field: 'lastModifiedDateTime', 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.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.CONTRACT_SERVICES]: 'Contract Services', [EntityType.AUTOTASK_SERVICES]: 'Autotask Services', [EntityType.TIME_ENTRIES]: 'Time Entries', [EntityType.TICKET_NOTES]: 'Ticket Notes', [EntityType.TAG_GROUPS]: 'Tag Groups', [EntityType.TAGS]: 'Tags', [EntityType.PROJECT_PHASES]: 'Project Phases', [EntityType.COMPANY_CATEGORIES]: 'Company Categories', [EntityType.COMPANY_TYPES]: 'Company Types', [EntityType.COMPANY_TEAMS]: 'Company Teams', }; return mapping[entity] || entity; }