wulf-pulse/lib/services/entity-sync.ts
root d8e6931b85 fix: ensure Companies and Resources get filters during incremental sync fallback
Fixed critical logic bug where Companies and Resources were not getting
proper filters applied when falling back from incremental to full sync.

Root Cause:
- During scheduled incremental sync, Companies and Resources correctly
  identify they don't support incremental sync
- However, the filter-building logic was in an else block that only
  executed for non-incremental syncs
- This caused Companies and Resources to sync with NO filters at all
- Autotask API now rejects queries without filters, returning:
  'Value cannot be null. Parameter name: filters'

Fix:
- Restructured logic so filter building happens for BOTH:
  1. Non-incremental (full) syncs
  2. Incremental syncs that fall back to full sync
- Companies and Resources now get active filters applied even during
  scheduled incremental syncs

This resolves the scheduled sync failures for Companies, Resources,
and all other entities that were failing due to the cascading effect
of early failures.
2026-01-30 22:50:07 -05:00

960 lines
37 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,
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);
}
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.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 });
// 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
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);
// Don't throw - soft delete failure shouldn't fail the entire sync
}
} 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 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);
}