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:
parent
fe9f806c50
commit
ff9e34cafe
8 changed files with 238 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue