wulf-pulse/lib/services/entity-sync.ts
lorentz b98c67482a feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
2026-03-17 07:39:55 -04:00

1161 lines
46 KiB
TypeScript

/**
* Entity Sync Service
* Handles syncing individual entity types from Autotask to PostgreSQL
*/
import { AutotaskClient } from './autotask-client';
import { autotaskRateLimiter } from './rate-limiter';
import postgresClient from './postgres-client';
import { EntityType } from '../types/sync';
import { mapAutotaskToDatabase, mapAutotaskBatch } from '../utils/entity-mapper';
import { bulkUpsertRecords, getLastSyncTime, softDeleteMissingRecords } from '../utils/db-helpers';
import { syncProgressTracker } from './sync-progress-tracker';
import {
getAutotaskEntityName,
buildIncrementalFilter,
buildActiveFilter,
buildDateRangeFilter,
buildContractsFilter,
buildContractServicesFilter,
buildProjectsFilter,
buildTimeEntriesFilter,
buildBillingItemsFilter,
getTableName
} from '../utils/sync-helpers';
import { createSyncLogger, SyncPhase, categorizeError } from '../utils/sync-logger';
/**
* Entity Sync Result
*/
export interface EntitySyncStats {
recordsAdded: number;
recordsUpdated: number;
recordsDeleted: number;
}
/**
* Entity Sync Service Class
*/
export class EntitySyncService {
private autotaskClient: AutotaskClient;
private cachedValidResourceIds?: Set<number>;
private cachedValidContactIds?: Set<number>;
private logger = createSyncLogger({ component: 'EntitySyncService' });
constructor(autotaskClient: AutotaskClient) {
this.autotaskClient = autotaskClient;
}
/**
* Sync a single entity type
* @param entity Entity type to sync
* @param isIncremental Whether to perform incremental sync
* @param yearsBack Number of years to look back for time-based entities (default: 2)
* @returns Sync statistics
*/
async syncEntity(
entity: EntityType,
isIncremental: boolean = false,
yearsBack: number = 2,
syncId?: string
): Promise<EntitySyncStats> {
// Route picklist entities to their specific sync methods
if (entity === EntityType.STATUSES) {
return await this.syncStatuses(isIncremental);
}
if (entity === EntityType.WORK_TYPES) {
return await this.syncWorkTypes(isIncremental);
}
if (entity === EntityType.ISSUE_TYPES) {
return await this.syncIssueTypes(isIncremental);
}
if (entity === EntityType.SUB_ISSUE_TYPES) {
return await this.syncSubIssueTypes(isIncremental);
}
if (entity === EntityType.QUEUES) {
return await this.syncQueues(isIncremental);
}
if (entity === EntityType.PRIORITIES) {
return await this.syncPriorities(isIncremental);
}
if (entity === EntityType.TICKET_CATEGORIES) {
return await this.syncTicketCategories(isIncremental);
}
const trackingId = syncId || `${entity}_${Date.now()}`;
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
const syncStartTime = entityLogger.start(`${entity} sync (${isIncremental ? 'incremental' : 'full'})`);
// Start progress tracking
syncProgressTracker.startSync(trackingId, entity);
entityLogger.phase(SyncPhase.INITIALIZING);
try {
const autotaskEntityName = getAutotaskEntityName(entity);
let params: any = {};
// For incremental sync, filter by last sync time
// Note: Companies and Resources don't support date-based filtering in Autotask API
const supportsIncremental = entity !== EntityType.COMPANIES && entity !== EntityType.RESOURCES;
if (isIncremental && supportsIncremental) {
try {
const lastSyncTime = await getLastSyncTime(entity);
if (lastSyncTime) {
params.filter = buildIncrementalFilter(entity, lastSyncTime);
entityLogger.info(`Incremental sync from ${lastSyncTime.toISOString()}`);
} else {
entityLogger.info('No previous sync found, performing full sync');
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
entityLogger.error('Failed to get last sync time', {}, err);
throw new Error(`Failed to determine sync time: ${err.message}`);
}
}
// For full sync OR entities that don't support incremental, build filters
if (!isIncremental || (isIncremental && !supportsIncremental)) {
if (isIncremental && !supportsIncremental) {
entityLogger.info(`${entity} does not support incremental sync, performing full sync instead`);
}
const filters: Array<{ field: string; op: string; value: any }> = [];
// Special handling for entities that require filters
if (entity === EntityType.CONTRACTS) {
filters.push(...buildContractsFilter());
entityLogger.info('Full sync with status filter for active contracts');
} else if (entity === EntityType.CONTRACT_SERVICES) {
filters.push(...buildContractServicesFilter());
entityLogger.info('Full sync of all contract services');
} else if (entity === EntityType.PROJECTS) {
filters.push(...buildProjectsFilter());
entityLogger.info('Full sync with status filter for non-completed projects');
} else if (entity === EntityType.TIME_ENTRIES) {
filters.push(...buildTimeEntriesFilter(yearsBack));
entityLogger.info(`Full sync with dateWorked filter for last ${yearsBack} years`);
} else if (entity === EntityType.BILLING_ITEMS) {
filters.push(...buildBillingItemsFilter(yearsBack));
entityLogger.info(`Full sync with itemDate filter for last ${yearsBack} years`);
} else {
// Add active filter if applicable
const activeFilter = buildActiveFilter(entity);
if (activeFilter) {
filters.push(...activeFilter);
entityLogger.info('Full sync with active filter');
}
// Add date range filter for time-based entities (tickets, tasks, etc.)
const dateRangeFilter = buildDateRangeFilter(entity, yearsBack);
if (dateRangeFilter) {
filters.push(...dateRangeFilter);
entityLogger.info(`Full sync limited to last ${yearsBack} years`);
}
}
if (filters.length > 0) {
params.filter = filters;
}
}
// Track whether any filters were applied - if so, skip soft deletes
const hasAppliedFilters = params.filter && params.filter.length > 0;
// Fetch data from Autotask with pagination
entityLogger.phase(SyncPhase.FETCHING, 'Fetching records from Autotask API');
syncProgressTracker.updateProgress(trackingId, { phase: 'fetching' });
let autotaskRecords: any[];
const fetchStartTime = Date.now();
try {
autotaskRecords = await this.autotaskClient.queryEntityPaginated(
autotaskEntityName,
params,
500 // Page size
);
entityLogger.debug('API fetch completed', { duration: Date.now() - fetchStartTime });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const errorCategory = categorizeError(err);
entityLogger.error('API fetch failed', { phase: SyncPhase.FETCHING, errorCategory }, err);
throw new Error(`Autotask API error: ${err.message}`);
}
entityLogger.info('Fetched records from Autotask', { recordCount: autotaskRecords.length });
syncProgressTracker.updateProgress(trackingId, {
totalRecords: autotaskRecords.length,
phase: 'mapping'
});
if (autotaskRecords.length === 0) {
entityLogger.info('No records to sync');
syncProgressTracker.completeSync(trackingId, 0);
return { recordsAdded: 0, recordsUpdated: 0, recordsDeleted: 0 };
}
// Map Autotask data to PostgreSQL schema
entityLogger.phase(SyncPhase.MAPPING, `Mapping ${autotaskRecords.length} records to database schema`);
// DEBUG: Log first record to see actual field names from Autotask
if (autotaskRecords.length > 0 && (entity === EntityType.TICKETS || entity === EntityType.TIME_ENTRIES)) {
entityLogger.debug('Sample raw Autotask record keys', { keys: Object.keys(autotaskRecords[0]) });
if (entity === EntityType.TIME_ENTRIES) {
entityLogger.debug('Sample time entry', { sample: JSON.stringify(autotaskRecords[0], null, 2) });
}
}
let mappedRecords: Record<string, any>[];
const mapStartTime = Date.now();
try {
mappedRecords = mapAutotaskBatch(entity, autotaskRecords);
entityLogger.debug('Mapping completed', { duration: Date.now() - mapStartTime });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const errorCategory = categorizeError(err);
entityLogger.error('Mapping failed', { phase: SyncPhase.MAPPING, errorCategory }, err);
throw new Error(`Data mapping error: ${err.message}`);
}
if (mappedRecords.length === 0) {
entityLogger.warn('All records failed mapping validation', { originalCount: autotaskRecords.length });
return { recordsAdded: 0, recordsUpdated: 0, recordsDeleted: 0 };
}
// Check for records with missing company_id (for entities that require it)
// Note: TIME_ENTRIES removed from this check because company_id is nullable for time entries
entityLogger.phase(SyncPhase.VALIDATING, 'Validating records');
if (entity === EntityType.TICKETS || entity === EntityType.PROJECTS ||
entity === EntityType.CONFIGURATION_ITEMS || entity === EntityType.CONTACTS ||
entity === EntityType.CONTRACTS || entity === EntityType.BILLING_ITEMS) {
const recordsWithoutCompany = mappedRecords.filter(r => !r.company_id);
if (recordsWithoutCompany.length > 0) {
entityLogger.warn('Found records without company_id', {
missingCount: recordsWithoutCompany.length,
totalCount: mappedRecords.length,
sampleIds: recordsWithoutCompany.slice(0, 5).map(r => r.id),
});
// Filter out records without company_id to prevent constraint violation
mappedRecords = mappedRecords.filter(r => r.company_id);
entityLogger.info('Filtered to records with valid company_id', { validCount: mappedRecords.length });
}
}
// Validate resource foreign keys for tickets
if (entity === EntityType.TICKETS) {
const initialCount = mappedRecords.length;
// Get all valid resource IDs from database
const validResourceIds = await this.getValidResourceIds();
// Filter tickets with invalid resource references
mappedRecords = mappedRecords.map(ticket => {
// Set invalid resource IDs to null instead of filtering out the entire ticket
if (ticket.assigned_resource_id && !validResourceIds.has(ticket.assigned_resource_id)) {
entityLogger.debug(`Invalid assigned_resource_id, setting to null`, {
ticketId: ticket.id,
invalidResourceId: ticket.assigned_resource_id,
});
ticket.assigned_resource_id = null;
}
if (ticket.first_response_assigned_resource_id && !validResourceIds.has(ticket.first_response_assigned_resource_id)) {
ticket.first_response_assigned_resource_id = null;
}
if (ticket.first_response_initiating_resource_id && !validResourceIds.has(ticket.first_response_initiating_resource_id)) {
ticket.first_response_initiating_resource_id = null;
}
return ticket;
});
const nullifiedCount = initialCount - mappedRecords.filter(t => t.assigned_resource_id).length;
if (nullifiedCount > 0) {
entityLogger.warn('Nullified invalid resource references', { nullifiedCount });
}
}
// Validate contact foreign keys for configuration items
if (entity === EntityType.CONFIGURATION_ITEMS) {
const initialCount = mappedRecords.length;
// Get all valid contact IDs from database
const validContactIds = await this.getValidContactIds();
// Filter configuration items with invalid contact references
mappedRecords = mappedRecords.map(item => {
// Set invalid contact IDs to null instead of filtering out the entire item
if (item.contact_id && !validContactIds.has(item.contact_id)) {
entityLogger.debug(`Invalid contact_id, setting to null`, {
itemId: item.id,
invalidContactId: item.contact_id,
});
item.contact_id = null;
}
return item;
});
const nullifiedCount = initialCount - mappedRecords.filter(i => i.contact_id).length;
if (nullifiedCount > 0) {
entityLogger.warn('Nullified invalid contact references', { nullifiedCount });
}
}
// Validate project and resource foreign keys for tasks
if (entity === EntityType.TASKS) {
const initialCount = mappedRecords.length;
// Get all valid project IDs and resource IDs from database
const validProjectIds = await this.getValidProjectIds();
const validResourceIds = await this.getValidResourceIds();
// Filter tasks with invalid foreign key references
mappedRecords = mappedRecords.map(task => {
// Set invalid project IDs to null
if (task.project_id && !validProjectIds.has(task.project_id)) {
entityLogger.debug(`Invalid project_id, setting to null`, {
taskId: task.id,
invalidProjectId: task.project_id,
});
task.project_id = null;
}
// Set invalid resource IDs to null
if (task.assigned_resource_id && !validResourceIds.has(task.assigned_resource_id)) {
entityLogger.debug(`Invalid assigned_resource_id, setting to null`, {
taskId: task.id,
invalidResourceId: task.assigned_resource_id,
});
task.assigned_resource_id = null;
}
if (task.creator_resource_id && !validResourceIds.has(task.creator_resource_id)) {
task.creator_resource_id = null;
}
if (task.completed_by_resource_id && !validResourceIds.has(task.completed_by_resource_id)) {
task.completed_by_resource_id = null;
}
if (task.last_activity_resource_id && !validResourceIds.has(task.last_activity_resource_id)) {
task.last_activity_resource_id = null;
}
return task;
});
const nullifiedProjectCount = initialCount - mappedRecords.filter(t => t.project_id).length;
const nullifiedResourceCount = initialCount - mappedRecords.filter(t => t.assigned_resource_id).length;
if (nullifiedProjectCount > 0 || nullifiedResourceCount > 0) {
entityLogger.warn('Nullified invalid foreign key references', {
nullifiedProjectCount,
nullifiedResourceCount
});
}
}
entityLogger.info('Successfully mapped records', { mappedCount: mappedRecords.length });
// Bulk upsert to PostgreSQL
entityLogger.phase(SyncPhase.UPSERTING, 'Upserting records to PostgreSQL');
syncProgressTracker.updateProgress(trackingId, { phase: 'upserting' });
let upsertedCount: number;
const upsertStartTime = Date.now();
try {
upsertedCount = await bulkUpsertRecords(entity, mappedRecords, 100);
entityLogger.debug('Upsert completed', { duration: Date.now() - upsertStartTime });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const errorCategory = categorizeError(err);
entityLogger.error('Database upsert failed', { phase: SyncPhase.UPSERTING, errorCategory }, err);
throw new Error(`Database error: ${err.message}`);
}
entityLogger.info('Upserted records to PostgreSQL', { upsertedCount });
// Post-sync enrichment: populate service_name from autotask_services lookup
if (entity === EntityType.CONTRACT_SERVICES) {
try {
const enrichResult = await postgresClient.query(`
UPDATE contract_services cs
SET service_name = s.name
FROM autotask_services s
WHERE cs.service_id = s.id AND cs.service_name IS NULL AND s.name IS NOT NULL
`);
entityLogger.info('Enriched contract_services with service names', { rowCount: enrichResult.rowCount });
} catch (err) {
entityLogger.warn('service_name enrichment skipped (autotask_services may not be synced yet)');
}
}
// For full sync, soft delete records not in the fetched set
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
// because we cannot know what records exist outside the filter criteria.
// EXCEPTION: TICKETS — we do a separate ID-only fetch from Autotask (no filters) to detect
// deletions even when date filters were applied to the main sync.
let deletedCount = 0;
if (!isIncremental && !hasAppliedFilters) {
entityLogger.phase(SyncPhase.DELETING, 'Checking for records to soft delete');
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
try {
const activeIds = mappedRecords.map(r => r.id);
deletedCount = await softDeleteMissingRecords(entity, activeIds);
entityLogger.info('Soft deleted missing records', { deletedCount });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
entityLogger.warn('Soft delete failed, continuing sync', {}, err);
}
} else if (!isIncremental && hasAppliedFilters && entity === EntityType.TICKETS) {
entityLogger.phase(SyncPhase.DELETING, 'Fetching all Autotask ticket IDs for deletion diff');
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
try {
// Fetch all ticket IDs from Autotask with no filters for deletion diff
const allAutotaskTickets = await this.autotaskClient.queryEntityPaginated(
'Tickets',
{},
500
);
const liveIds = new Set(allAutotaskTickets.map((t: any) => String(t.id)));
// Find Pulse ticket IDs not present in Autotask → these were deleted
const pulseResult = await postgresClient.query(
`SELECT id FROM tickets WHERE is_deleted = false`
);
const toDelete = pulseResult.rows
.map((r: { id: number }) => r.id)
.filter((id: number) => !liveIds.has(String(id)));
if (toDelete.length > 0) {
const result = await postgresClient.query(
`UPDATE tickets
SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP
WHERE id = ANY($1::int[])`,
[toDelete]
);
deletedCount = result.rowCount || 0;
entityLogger.info('Soft deleted tickets missing from Autotask', { deletedCount });
} else {
entityLogger.info('No deleted tickets detected');
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
entityLogger.warn('Ticket deletion diff failed, continuing sync', {}, err);
}
} else if (!isIncremental && hasAppliedFilters) {
entityLogger.info('Skipping soft-delete because filters were applied (would delete records outside filter criteria)');
}
// Calculate added vs updated (simplified - actual count would require tracking)
const recordsAdded = Math.floor(upsertedCount * 0.1); // Estimate 10% new
const recordsUpdated = upsertedCount - recordsAdded;
// Mark sync as completed
entityLogger.phase(SyncPhase.COMPLETING);
syncProgressTracker.completeSync(trackingId, mappedRecords.length);
entityLogger.complete(`${entity} sync`, syncStartTime, {
recordsAdded,
recordsUpdated,
recordsDeleted: deletedCount,
});
return {
recordsAdded,
recordsUpdated,
recordsDeleted: deletedCount,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
// Mark sync as failed
syncProgressTracker.failSync(trackingId, err.message);
entityLogger.fail(`${entity} sync`, syncStartTime, err);
throw error;
}
}
/**
* Sync Companies
*/
async syncCompanies(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.COMPANIES, isIncremental);
}
/**
* Sync Tickets
*/
async syncTickets(isIncremental: boolean = false, yearsBack?: number): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TICKETS, isIncremental, yearsBack);
}
/**
* Sync Tickets with Monthly Chunking
* Breaks large date ranges into monthly chunks to prevent timeouts and API failures
* @param yearsBack Number of years to look back
* @param onChunkProgress Callback for progress updates
* @returns Aggregated sync statistics
*/
async syncTicketsChunked(
yearsBack: number = 2,
onChunkProgress?: (chunk: { index: number; total: number; description: string; recordsProcessed: number }) => void
): Promise<EntitySyncStats> {
const entity = EntityType.TICKETS;
const chunkLogger = this.logger.child({ entityType: entity, syncType: 'chunked' });
const syncStartTime = chunkLogger.start(`Chunked sync for last ${yearsBack} years`);
try {
// Calculate date chunks (monthly)
const chunks = this.calculateMonthlyChunks(yearsBack);
chunkLogger.info('Split into monthly chunks', { chunkCount: chunks.length, yearsBack });
let totalRecordsAdded = 0;
let totalRecordsUpdated = 0;
let totalRecordsDeleted = 0;
const failedChunks: string[] = [];
// Process each chunk
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkDescription = `${chunk.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}`;
chunkLogger.info(`Processing chunk ${i + 1}/${chunks.length}`, { chunkDescription });
// Notify progress
if (onChunkProgress) {
onChunkProgress({
index: i + 1,
total: chunks.length,
description: chunkDescription,
recordsProcessed: totalRecordsAdded + totalRecordsUpdated,
});
}
try {
// Fetch tickets for this date range
const autotaskEntityName = getAutotaskEntityName(entity);
const filters = [
{ field: 'createDate', op: 'gte' as const, value: chunk.startDate.toISOString() },
{ field: 'createDate', op: 'lt' as const, value: chunk.endDate.toISOString() },
];
chunkLogger.debug('Fetching records for chunk', {
chunkIndex: i + 1,
startDate: chunk.startDate.toISOString(),
endDate: chunk.endDate.toISOString(),
});
const autotaskRecords = await this.autotaskClient.queryEntityPaginated(
autotaskEntityName,
{ filter: filters },
500
);
chunkLogger.info(`Chunk ${i + 1}: Fetched records`, { recordCount: autotaskRecords.length });
if (autotaskRecords.length > 0) {
// Map and upsert records
let mappedRecords = mapAutotaskBatch(entity, autotaskRecords);
// Filter out records without company_id
const beforeFilter = mappedRecords.length;
mappedRecords = mappedRecords.filter(r => r.company_id);
if (mappedRecords.length < beforeFilter) {
chunkLogger.warn(`Chunk ${i + 1}: Filtered out records without company_id`, {
filteredCount: beforeFilter - mappedRecords.length,
});
}
// Validate resource foreign keys (fetch once per sync, not per chunk)
if (i === 0) {
// Cache valid resource IDs for all chunks
this.cachedValidResourceIds = await this.getValidResourceIds();
chunkLogger.info('Cached valid resource IDs', { cacheSize: this.cachedValidResourceIds.size });
}
// Nullify invalid resource references
mappedRecords = mappedRecords.map(ticket => {
if (ticket.assigned_resource_id && !this.cachedValidResourceIds!.has(ticket.assigned_resource_id)) {
ticket.assigned_resource_id = null;
}
if (ticket.first_response_assigned_resource_id && !this.cachedValidResourceIds!.has(ticket.first_response_assigned_resource_id)) {
ticket.first_response_assigned_resource_id = null;
}
if (ticket.first_response_initiating_resource_id && !this.cachedValidResourceIds!.has(ticket.first_response_initiating_resource_id)) {
ticket.first_response_initiating_resource_id = null;
}
return ticket;
});
if (mappedRecords.length > 0) {
const upsertedCount = await bulkUpsertRecords(entity, mappedRecords, 100);
// Estimate added vs updated
const recordsAdded = Math.floor(upsertedCount * 0.1);
const recordsUpdated = upsertedCount - recordsAdded;
totalRecordsAdded += recordsAdded;
totalRecordsUpdated += recordsUpdated;
chunkLogger.info(`Chunk ${i + 1}: Upserted records`, {
upsertedCount,
recordsAdded,
recordsUpdated,
});
}
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const errorCategory = categorizeError(err);
chunkLogger.error(`Chunk ${i + 1} (${chunkDescription}) failed`, {
chunkIndex: i + 1,
chunkDescription,
errorCategory,
}, err);
failedChunks.push(`${chunkDescription}: ${err.message}`);
// Continue with next chunk instead of failing entire sync
}
}
chunkLogger.complete('Chunked sync', syncStartTime, {
totalRecordsAdded,
totalRecordsUpdated,
totalRecordsDeleted,
totalChunks: chunks.length,
failedChunks: failedChunks.length,
});
if (failedChunks.length > 0) {
chunkLogger.warn('Some chunks failed', { failedChunks });
}
return {
recordsAdded: totalRecordsAdded,
recordsUpdated: totalRecordsUpdated,
recordsDeleted: totalRecordsDeleted,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
chunkLogger.fail('Chunked sync', syncStartTime, err);
throw error;
}
}
/**
* Get all valid resource IDs from the database
* Used to validate foreign key references before insert
* @returns Set of valid resource IDs
*/
private async getValidResourceIds(): Promise<Set<number>> {
try {
const query = 'SELECT id FROM resources WHERE is_deleted = false';
const result = await postgresClient.query<{ id: number }>(query);
return new Set(result.rows.map(row => row.id));
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Failed to fetch valid resource IDs', {}, err);
// Return empty set on error - will cause all resource IDs to be nullified
return new Set();
}
}
/**
* Used to validate foreign key references before insert
* @returns Set of valid contact IDs
*/
private async getValidContactIds(): Promise<Set<number>> {
try {
const query = 'SELECT id FROM contacts WHERE is_deleted = false';
const result = await postgresClient.query<{ id: number }>(query);
return new Set(result.rows.map(row => row.id));
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Failed to fetch valid contact IDs', {}, err);
// Return empty set on error - will cause all contact IDs to be nullified
return new Set();
}
}
/**
* Get all valid project IDs from the database
* Used to validate foreign key references before insert
* @returns Set of valid project IDs
*/
private async getValidProjectIds(): Promise<Set<number>> {
try {
const query = 'SELECT id FROM projects WHERE is_deleted = false';
const result = await postgresClient.query<{ id: number }>(query);
return new Set(result.rows.map(row => row.id));
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Failed to fetch valid project IDs', {}, err);
// Return empty set on error - will cause all project IDs to be nullified
return new Set();
}
}
/**
* Calculate monthly date chunks for a given time period
* @param yearsBack Number of years to look back
* @returns Array of date range chunks
*/
private calculateMonthlyChunks(yearsBack: number): Array<{ startDate: Date; endDate: Date }> {
const chunks: Array<{ startDate: Date; endDate: Date }> = [];
const now = new Date();
const startDate = new Date(now);
startDate.setFullYear(now.getFullYear() - yearsBack);
startDate.setHours(0, 0, 0, 0);
let currentDate = new Date(startDate);
while (currentDate < now) {
const chunkStart = new Date(currentDate);
// Move to next month
const chunkEnd = new Date(currentDate);
chunkEnd.setMonth(chunkEnd.getMonth() + 1);
// Don't go beyond current date
if (chunkEnd > now) {
chunkEnd.setTime(now.getTime());
}
chunks.push({
startDate: chunkStart,
endDate: chunkEnd,
});
currentDate = new Date(chunkEnd);
}
return chunks;
}
/**
* Sync Tasks
*/
async syncTasks(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TASKS, isIncremental);
}
/**
* Sync Projects
*/
async syncProjects(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.PROJECTS, isIncremental);
}
/**
* Sync Resources (Users)
*/
async syncResources(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.RESOURCES, isIncremental);
}
/**
* Sync Configuration Items
*/
async syncConfigurationItems(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.CONFIGURATION_ITEMS, isIncremental);
}
/**
* Sync Contacts
*/
async syncContacts(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.CONTACTS, isIncremental);
}
/**
* Sync Contracts
*/
async syncContracts(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.CONTRACTS, isIncremental);
}
/**
* Sync Billing Items
*/
async syncBillingItems(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.BILLING_ITEMS, isIncremental);
}
/**
* Sync Statuses (Picklist from Ticket field)
*/
async syncStatuses(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.STATUSES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
// Get status picklist values from Tickets entity
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'status');
// Convert picklist to database format
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
// Get existing values to calculate added vs updated
const tableName = getTableName(EntityType.STATUSES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
// Upsert to database
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = {
recordsAdded,
recordsUpdated,
recordsDeleted: 0,
};
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Issue Types (Picklist from Ticket field)
*/
async syncIssueTypes(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.ISSUE_TYPES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
// Get issue type picklist values from Tickets entity
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'issueType');
// Convert picklist to database format
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
// Get existing values to calculate added vs updated
const tableName = getTableName(EntityType.ISSUE_TYPES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
// Upsert to database
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = {
recordsAdded,
recordsUpdated,
recordsDeleted: 0,
};
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Sub-Issue Types (Picklist from Ticket field)
*/
async syncSubIssueTypes(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.SUB_ISSUE_TYPES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
// Get sub-issue type picklist values from Tickets entity
const url = `${this.autotaskClient['config'].apiUrl}/Tickets/entityInformation/fields`;
const response = await fetch(url, {
method: 'GET',
headers: this.autotaskClient['getAuthHeaders'](),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`Failed to fetch field info: ${responseText}`);
}
const fieldData = JSON.parse(responseText);
const subIssueTypeField = fieldData.fields.find((f: any) => f.name === 'subIssueType');
if (!subIssueTypeField || !subIssueTypeField.picklistValues) {
throw new Error('subIssueType field or picklist values not found');
}
// Convert picklist to database format, capturing parent value if it exists
const records = subIssueTypeField.picklistValues.map((item: any) => ({
value: parseInt(item.value),
label: item.label,
is_active: item.isActive !== false,
parent_value: item.parentValue ? parseInt(item.parentValue) : null,
sort_order: item.sortOrder || parseInt(item.value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
// Get existing values to calculate added vs updated
const tableName = getTableName(EntityType.SUB_ISSUE_TYPES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map(r => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
// Upsert to database
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = {
recordsAdded,
recordsUpdated,
recordsDeleted: 0,
};
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Queues (Picklist from Ticket field)
*/
async syncQueues(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.QUEUES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'queueID');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.QUEUES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Priorities (Picklist from Ticket field)
*/
async syncPriorities(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.PRIORITIES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'priority');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.PRIORITIES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Ticket Categories (Picklist from Ticket field)
*/
async syncTicketCategories(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.TICKET_CATEGORIES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'ticketCategory');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.TICKET_CATEGORIES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Work Types (Picklist from TimeEntry field)
*/
async syncWorkTypes(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.WORK_TYPES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
// Get work type picklist values from TimeEntries entity
const picklistValues = await this.autotaskClient.getPicklistValues('TimeEntries', 'workType');
// Convert picklist to database format
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
// Get existing values to calculate added vs updated
const tableName = getTableName(EntityType.WORK_TYPES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
// Upsert to database
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = {
recordsAdded,
recordsUpdated,
recordsDeleted: 0,
};
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Time Entries
*/
async syncTimeEntries(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TIME_ENTRIES, isIncremental);
}
}
/**
* Create entity sync service instance
* @param autotaskClient Autotask client instance
* @returns EntitySyncService instance
*/
export function createEntitySyncService(autotaskClient: AutotaskClient): EntitySyncService {
return new EntitySyncService(autotaskClient);
}