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:
parent
44db9a3019
commit
dd4cf68def
5 changed files with 131 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import {
|
||||||
buildContractsFilter,
|
buildContractsFilter,
|
||||||
buildContractServicesFilter,
|
buildContractServicesFilter,
|
||||||
buildProjectsFilter,
|
buildProjectsFilter,
|
||||||
|
buildProjectPhasesFilter,
|
||||||
buildTimeEntriesFilter,
|
buildTimeEntriesFilter,
|
||||||
buildBillingItemsFilter,
|
buildBillingItemsFilter,
|
||||||
getTableName
|
getTableName
|
||||||
|
|
@ -132,6 +133,9 @@ export class EntitySyncService {
|
||||||
} else if (entity === EntityType.PROJECTS) {
|
} else if (entity === EntityType.PROJECTS) {
|
||||||
filters.push(...buildProjectsFilter());
|
filters.push(...buildProjectsFilter());
|
||||||
entityLogger.info('Full sync with status filter for non-completed projects');
|
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) {
|
} else if (entity === EntityType.TIME_ENTRIES) {
|
||||||
filters.push(...buildTimeEntriesFilter(yearsBack));
|
filters.push(...buildTimeEntriesFilter(yearsBack));
|
||||||
entityLogger.info(`Full sync with dateWorked filter for last ${yearsBack} years`);
|
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
|
// For full sync, soft delete records not in the fetched set
|
||||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||||
// because we cannot know what records exist outside the filter criteria.
|
// 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);
|
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)
|
* Sync Resources (Users)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export enum EntityType {
|
||||||
TICKET_NOTES = 'ticket_notes',
|
TICKET_NOTES = 'ticket_notes',
|
||||||
TAG_GROUPS = 'autotask_tag_groups',
|
TAG_GROUPS = 'autotask_tag_groups',
|
||||||
TAGS = 'autotask_tags',
|
TAGS = 'autotask_tags',
|
||||||
|
PROJECT_PHASES = 'project_phases',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync operation types
|
// Sync operation types
|
||||||
|
|
@ -175,6 +176,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
||||||
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||||
[EntityType.TAG_GROUPS]: [], // No dependencies — standalone lookup
|
[EntityType.TAG_GROUPS]: [], // No dependencies — standalone lookup
|
||||||
[EntityType.TAGS]: [EntityType.TAG_GROUPS], // Depends on tag groups (FK)
|
[EntityType.TAGS]: [EntityType.TAG_GROUPS], // Depends on tag groups (FK)
|
||||||
|
[EntityType.PROJECT_PHASES]: [EntityType.PROJECTS], // Depends on projects
|
||||||
};
|
};
|
||||||
|
|
||||||
// Autotask API field names (for incremental sync)
|
// Autotask API field names (for incremental sync)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ export function mapAutotaskToDatabase(
|
||||||
case EntityType.PROJECTS:
|
case EntityType.PROJECTS:
|
||||||
mapped = mapProject(data);
|
mapped = mapProject(data);
|
||||||
break;
|
break;
|
||||||
|
case EntityType.PROJECT_PHASES:
|
||||||
|
mapped = mapProjectPhase(data);
|
||||||
|
break;
|
||||||
case EntityType.RESOURCES:
|
case EntityType.RESOURCES:
|
||||||
mapped = mapResource(data);
|
mapped = mapResource(data);
|
||||||
break;
|
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
|
* Map Resource entity
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
||||||
EntityType.TICKET_CATEGORIES,
|
EntityType.TICKET_CATEGORIES,
|
||||||
EntityType.CONTACTS,
|
EntityType.CONTACTS,
|
||||||
EntityType.PROJECTS,
|
EntityType.PROJECTS,
|
||||||
|
EntityType.PROJECT_PHASES,
|
||||||
EntityType.TICKETS,
|
EntityType.TICKETS,
|
||||||
EntityType.TASKS,
|
EntityType.TASKS,
|
||||||
EntityType.CONFIGURATION_ITEMS,
|
EntityType.CONFIGURATION_ITEMS,
|
||||||
|
|
@ -126,6 +127,7 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
||||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||||
[EntityType.TAG_GROUPS]: 'TagGroups',
|
[EntityType.TAG_GROUPS]: 'TagGroups',
|
||||||
[EntityType.TAGS]: 'Tags',
|
[EntityType.TAGS]: 'Tags',
|
||||||
|
[EntityType.PROJECT_PHASES]: 'Phases',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || entity;
|
return mapping[entity] || entity;
|
||||||
|
|
@ -177,6 +179,7 @@ export function getLastModifiedField(entity: EntityType): string {
|
||||||
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
|
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
|
||||||
[EntityType.TAG_GROUPS]: 'lastModifiedDate',
|
[EntityType.TAG_GROUPS]: 'lastModifiedDate',
|
||||||
[EntityType.TAGS]: 'lastModifiedDateTime',
|
[EntityType.TAGS]: 'lastModifiedDateTime',
|
||||||
|
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || 'lastModifiedDate';
|
return mapping[entity] || 'lastModifiedDate';
|
||||||
|
|
@ -211,6 +214,7 @@ export function getActiveField(entity: EntityType): string | null {
|
||||||
[EntityType.TICKET_CATEGORIES]: 'isActive',
|
[EntityType.TICKET_CATEGORIES]: 'isActive',
|
||||||
[EntityType.TAG_GROUPS]: 'isActive',
|
[EntityType.TAG_GROUPS]: 'isActive',
|
||||||
[EntityType.TAGS]: 'isActive',
|
[EntityType.TAGS]: 'isActive',
|
||||||
|
[EntityType.PROJECT_PHASES]: null, // No active field on phases
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || null;
|
return mapping[entity] || null;
|
||||||
|
|
@ -305,6 +309,7 @@ export function buildDateRangeFilter(
|
||||||
[EntityType.QUEUES]: null,
|
[EntityType.QUEUES]: null,
|
||||||
[EntityType.PRIORITIES]: null,
|
[EntityType.PRIORITIES]: null,
|
||||||
[EntityType.TICKET_CATEGORIES]: null,
|
[EntityType.TICKET_CATEGORIES]: null,
|
||||||
|
[EntityType.PROJECT_PHASES]: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateField = dateFieldMapping[entity];
|
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)
|
* Build special filter for projects (requires status filter)
|
||||||
* @returns Query filter array for active projects
|
* @returns Query filter array for active projects
|
||||||
|
|
@ -498,6 +517,7 @@ export function getEntityDisplayName(entity: EntityType): string {
|
||||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||||
[EntityType.TAG_GROUPS]: 'Tag Groups',
|
[EntityType.TAG_GROUPS]: 'Tag Groups',
|
||||||
[EntityType.TAGS]: 'Tags',
|
[EntityType.TAGS]: 'Tags',
|
||||||
|
[EntityType.PROJECT_PHASES]: 'Project Phases',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || entity;
|
return mapping[entity] || entity;
|
||||||
|
|
|
||||||
30
migrations/059_create_project_phases_table.sql
Normal file
30
migrations/059_create_project_phases_table.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Migration 059: Create project_phases table
|
||||||
|
-- Project phases are groupings of tasks within an Autotask project.
|
||||||
|
-- Tasks reference phases via phase_id. Syncing phases allows us to resolve
|
||||||
|
-- tasks back to their parent project (tasks.phase_id → project_phases.id → project_phases.project_id).
|
||||||
|
-- Autotask endpoint: /Phases
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_phases (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
project_id BIGINT, -- no FK: phases may reference completed/archived projects not in our DB
|
||||||
|
parent_phase_id BIGINT,
|
||||||
|
title CHARACTER VARYING,
|
||||||
|
description TEXT,
|
||||||
|
phase_number CHARACTER VARYING,
|
||||||
|
estimated_hours NUMERIC,
|
||||||
|
start_date_time TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
due_date TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
create_date_time TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
last_activity_date_time TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
creator_resource_id BIGINT,
|
||||||
|
is_scheduled BOOLEAN DEFAULT FALSE,
|
||||||
|
external_id CHARACTER VARYING,
|
||||||
|
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_phases_project_id ON project_phases (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_phases_is_deleted ON project_phases (is_deleted) WHERE is_deleted = FALSE;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue