feat: add project_phases entity sync with task project_id backfill

The Autotask Tasks bulk API does not return projectID in its response,
causing all tasks.project_id to be NULL. This fixes it by:

- Adding project_phases as a synced entity (Autotask endpoint: /Phases)
- Migration 059: project_phases table with project_id, phase_number,
  estimated_hours, start/due dates, parent_phase_id, is_scheduled
- EntityType.PROJECT_PHASES added to all sync maps and dependency graph
  (depends on PROJECTS, runs before TASKS in sync order)
- buildProjectPhasesFilter: Phases endpoint requires a filter (id > 0)
- mapProjectPhase: maps Autotask field names to DB columns
- Post-sync backfill in syncEntity: after each project_phases sync,
  UPDATE tasks SET project_id = pp.project_id FROM project_phases pp
  JOIN projects p WHERE tasks.phase_id = pp.id
  Only backfills where the project exists in our DB (FK constraint on
  tasks.project_id; archived projects are skipped gracefully)

Result: 2,455 of 4,966 tasks now have project_id populated. Tasks
belonging to archived/completed projects have phase_id resolvable via
project_phases even when project_id remains NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-03-24 13:57:33 -04:00
parent 44db9a3019
commit dd4cf68def
5 changed files with 131 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import {
buildContractsFilter,
buildContractServicesFilter,
buildProjectsFilter,
buildProjectPhasesFilter,
buildTimeEntriesFilter,
buildBillingItemsFilter,
getTableName
@ -132,6 +133,9 @@ export class EntitySyncService {
} else if (entity === EntityType.PROJECTS) {
filters.push(...buildProjectsFilter());
entityLogger.info('Full sync with status filter for non-completed projects');
} else if (entity === EntityType.PROJECT_PHASES) {
filters.push(...buildProjectPhasesFilter());
entityLogger.info('Full sync of all project phases');
} else if (entity === EntityType.TIME_ENTRIES) {
filters.push(...buildTimeEntriesFilter(yearsBack));
entityLogger.info(`Full sync with dateWorked filter for last ${yearsBack} years`);
@ -384,6 +388,26 @@ export class EntitySyncService {
}
}
// Post-sync backfill: after syncing phases, populate tasks.project_id via phase → project join.
// The Autotask Tasks bulk query does not return projectID — it must be resolved through phaseID.
if (entity === EntityType.PROJECT_PHASES) {
try {
const backfillResult = await postgresClient.query(
`UPDATE tasks
SET project_id = pp.project_id
FROM project_phases pp
JOIN projects p ON p.id = pp.project_id
WHERE tasks.phase_id = pp.id
AND tasks.project_id IS DISTINCT FROM pp.project_id
AND pp.project_id IS NOT NULL`
);
const updated = (backfillResult as any).rowCount ?? 0;
entityLogger.info('Backfilled tasks.project_id via phase → project join', { updatedTaskCount: updated });
} catch (err) {
entityLogger.warn('Task project_id backfill failed', { error: String(err) });
}
}
// 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.
@ -744,6 +768,34 @@ export class EntitySyncService {
return await this.syncEntity(EntityType.PROJECTS, isIncremental);
}
/**
* Sync Project Phases, then backfill project_id on tasks that reference those phases.
* Autotask's Tasks bulk query does not return projectID it must be resolved via phaseID.
*/
async syncProjectPhases(isIncremental: boolean = false): Promise<EntitySyncStats> {
const stats = await this.syncEntity(EntityType.PROJECT_PHASES, isIncremental);
// Backfill tasks.project_id using the newly synced phases
try {
const result = await postgresClient.query<{ rowCount: number }>(
`UPDATE tasks
SET project_id = pp.project_id
FROM project_phases pp
WHERE tasks.phase_id = pp.id
AND tasks.project_id IS DISTINCT FROM pp.project_id
AND pp.project_id IS NOT NULL`
);
const updated = (result as any).rowCount ?? 0;
if (updated > 0) {
this.logger.info(`Backfilled project_id on ${updated} tasks via phase → project join`);
}
} catch (err) {
this.logger.warn('Task project_id backfill failed', { error: String(err) });
}
return stats;
}
/**
* Sync Resources (Users)
*/