feat: add Autotask tags sync (tag groups, tags, ticket tag associations)

- Migration 057: autotask_tag_groups, autotask_tags, and junction tables
  (ticket_tags, company_tags, configuration_item_tags, contact_tags)
- Add TAG_GROUPS and TAGS to EntityType enum and dependency map
- Add mapTagGroup() and mapTag() entity mapper functions
- Add syncTagGroups(), syncTags(), syncTicketTagAssociations() methods
- Wire TicketTagAssociations bulk sync into full/incremental sync flow
- Add 'exist' operator to QueryFilter type
- No FK on ticket_id (tagged tickets may be outside 2yr sync window)

Synced: 26 tag groups, 7299 tags, 10141 ticket-tag associations
This commit is contained in:
lorentz 2026-03-20 09:22:40 -04:00
parent fe9f806c50
commit ff9e34cafe
8 changed files with 238 additions and 3 deletions

View file

@ -587,7 +587,7 @@ export class EntitySyncService {
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;
@ -1149,6 +1149,96 @@ export class EntitySyncService {
async syncTimeEntries(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TIME_ENTRIES, isIncremental);
}
/**
* Sync Tag Groups
*/
async syncTagGroups(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TAG_GROUPS, isIncremental);
}
/**
* Sync Tags
*/
async syncTags(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.TAGS, isIncremental);
}
/**
* Sync TicketTagAssociations from Autotask API into the ticket_tags junction table.
* Autotask exposes TicketTagAssociations as a bulk-queryable entity with fields:
* id, tagID, ticketID
*/
async syncTicketTagAssociations(): Promise<EntitySyncStats> {
const tagLogger = this.logger.child({ entityType: 'ticket_tag_associations', syncType: 'full' });
const syncStartTime = tagLogger.start('TicketTagAssociations sync');
try {
// Fetch all TicketTagAssociations from Autotask
tagLogger.info('Fetching TicketTagAssociations from Autotask');
const associations = await this.autotaskClient.queryEntityPaginated<{
id: number;
tagID: number;
ticketID: number;
}>('TicketTagAssociations', { filter: [{ op: 'exist', field: 'id' }] }, 500);
tagLogger.info('Fetched TicketTagAssociations', { count: associations.length });
if (associations.length === 0) {
tagLogger.info('No ticket tag associations found');
tagLogger.complete('TicketTagAssociations sync', syncStartTime, { recordsAdded: 0 });
return { recordsAdded: 0, recordsUpdated: 0, recordsDeleted: 0 };
}
// Validate tag IDs exist in our DB to avoid FK violations on autotask_tags
// (ticket_id has no FK constraint since tagged tickets may be outside sync window)
const validTagResult = await postgresClient.query<{ id: number }>(
'SELECT id FROM autotask_tags'
);
const validTagIds = new Set(validTagResult.rows.map(r => Number(r.id)));
const validAssociations = associations.filter(a => validTagIds.has(Number(a.tagID)));
tagLogger.info('Validated associations', {
total: associations.length,
valid: validAssociations.length,
skippedInvalidTag: associations.length - validAssociations.length,
});
// Replace all ticket_tags: truncate and re-insert for a clean full sync
await postgresClient.query('DELETE FROM ticket_tags');
const CHUNK = 500;
let inserted = 0;
for (let i = 0; i < validAssociations.length; i += CHUNK) {
const chunk = validAssociations.slice(i, i + CHUNK);
const values = chunk
.map((_, idx) => `($${idx * 2 + 1}::bigint, $${idx * 2 + 2}::bigint, NOW())`)
.join(', ');
const params = chunk.flatMap(a => [a.ticketID, a.tagID]);
await postgresClient.query(
`INSERT INTO ticket_tags (ticket_id, tag_id, synced_at)
VALUES ${values}
ON CONFLICT (ticket_id, tag_id) DO UPDATE SET synced_at = NOW()`,
params
);
inserted += chunk.length;
}
const stats: EntitySyncStats = {
recordsAdded: inserted,
recordsUpdated: 0,
recordsDeleted: 0,
};
tagLogger.complete('TicketTagAssociations sync', syncStartTime, stats);
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
tagLogger.fail('TicketTagAssociations sync', syncStartTime, err);
throw error;
}
}
}
/**