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

@ -39,6 +39,9 @@ export function mapAutotaskToDatabase(
case EntityType.PROJECTS:
mapped = mapProject(data);
break;
case EntityType.PROJECT_PHASES:
mapped = mapProjectPhase(data);
break;
case EntityType.RESOURCES:
mapped = mapResource(data);
break;
@ -344,6 +347,30 @@ function mapProject(data: any): Record<string, any> {
};
}
/**
* Map ProjectPhase entity
*/
function mapProjectPhase(data: any): Record<string, any> {
return {
id: data.id,
project_id: data.projectID,
title: data.title,
description: data.description,
phase_number: data.phaseNumber,
estimated_hours: data.estimatedHours,
start_date_time: data.startDate,
due_date: data.dueDate,
create_date_time: data.createDate,
last_activity_date_time: data.lastActivityDateTime,
parent_phase_id: data.parentPhaseID,
is_scheduled: data.isScheduled,
creator_resource_id: data.creatorResourceID,
external_id: data.externalID,
synced_at: data.synced_at,
is_deleted: data.is_deleted || false,
};
}
/**
* Map Resource entity
*/

View file

@ -61,6 +61,7 @@ export function getAllEntitiesInOrder(): EntityType[] {
EntityType.TICKET_CATEGORIES,
EntityType.CONTACTS,
EntityType.PROJECTS,
EntityType.PROJECT_PHASES,
EntityType.TICKETS,
EntityType.TASKS,
EntityType.CONFIGURATION_ITEMS,
@ -126,6 +127,7 @@ export function getAutotaskEntityName(entity: EntityType): string {
[EntityType.TICKET_NOTES]: 'TicketNotes',
[EntityType.TAG_GROUPS]: 'TagGroups',
[EntityType.TAGS]: 'Tags',
[EntityType.PROJECT_PHASES]: 'Phases',
};
return mapping[entity] || entity;
@ -177,6 +179,7 @@ export function getLastModifiedField(entity: EntityType): string {
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
[EntityType.TAG_GROUPS]: 'lastModifiedDate',
[EntityType.TAGS]: 'lastModifiedDateTime',
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
};
return mapping[entity] || 'lastModifiedDate';
@ -211,6 +214,7 @@ export function getActiveField(entity: EntityType): string | null {
[EntityType.TICKET_CATEGORIES]: 'isActive',
[EntityType.TAG_GROUPS]: 'isActive',
[EntityType.TAGS]: 'isActive',
[EntityType.PROJECT_PHASES]: null, // No active field on phases
};
return mapping[entity] || null;
@ -305,6 +309,7 @@ export function buildDateRangeFilter(
[EntityType.QUEUES]: null,
[EntityType.PRIORITIES]: null,
[EntityType.TICKET_CATEGORIES]: null,
[EntityType.PROJECT_PHASES]: null,
};
const dateField = dateFieldMapping[entity];
@ -347,6 +352,20 @@ export function buildContractsFilter(): Array<{ field: string; op: string; value
];
}
/**
* 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
@ -498,6 +517,7 @@ export function getEntityDisplayName(entity: EntityType): string {
[EntityType.TICKET_NOTES]: 'Ticket Notes',
[EntityType.TAG_GROUPS]: 'Tag Groups',
[EntityType.TAGS]: 'Tags',
[EntityType.PROJECT_PHASES]: 'Project Phases',
};
return mapping[entity] || entity;