wulf-pulse/lib/services/ticket-reconciliation-service.ts
lorentz 51f0b32cb3 feat(260521-fci-01): add ticket reconciliation service + API route
- New lib/services/ticket-reconciliation-service.ts: reconcileStaleTickets()
  scans tickets where is_deleted=false AND status<>5 AND synced_at older than
  7 days (capped at 500), re-fetches each from Autotask, and either upserts
  via the webhook SQL pattern or soft-deletes when Autotask returns null.
- Returns { scanned, updated, statusFlippedToComplete, softDeleted, errors }.
- New POST /api/sync/reconcile-tickets — fire-and-forget trigger mirroring
  /api/sync/incremental (public per existing middleware allowlist).
2026-05-21 11:08:24 -04:00

151 lines
5 KiB
TypeScript

/**
* Ticket Reconciliation Service (STOPGAP)
*
* Nightly backstop for drift between the postgres `tickets` mirror and Autotask.
* Webhooks can drop events and the incremental sync's high-water-mark filter
* can miss tickets whose lastActivityDate never advances after a status change.
* This job bounds local staleness at 7 days by re-fetching each stale-open
* ticket from Autotask and either re-upserting or soft-deleting.
*
* NOTE: This is a stopgap. The underlying incremental-filter issue is tracked
* separately and should fix the root cause.
*/
import { postgresClient } from './postgres-client';
import { AutotaskClient } from './autotask-client';
import { autotaskRateLimiter } from './rate-limiter';
import { mapAutotaskToDatabase } from '../utils/entity-mapper';
import { getTableName } from '../utils/sync-helpers';
import { EntityType } from '../types/sync';
import { createSyncLogger } from '../utils/sync-logger';
export interface ReconcileResult {
scanned: number;
updated: number;
statusFlippedToComplete: number;
softDeleted: number;
errors: number;
}
const STALE_DAYS = 7;
const SCAN_LIMIT = 500;
const COMPLETE_STATUS = 5; // Autotask status id for Complete
let _client: AutotaskClient | null = null;
function getClient(): AutotaskClient {
if (!_client) {
_client = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return _client;
}
/**
* Re-fetch up to SCAN_LIMIT stale-open tickets from Autotask and reconcile
* the local mirror. Idempotent and safe to run alongside the regular sync.
*/
export async function reconcileStaleTickets(): Promise<ReconcileResult> {
const logger = createSyncLogger({ component: 'TicketReconciliation' });
const startedAt = Date.now();
const result: ReconcileResult = {
scanned: 0,
updated: 0,
statusFlippedToComplete: 0,
softDeleted: 0,
errors: 0,
};
// 1. Find stale-open tickets in the postgres mirror.
const staleQuery = `
SELECT id, status
FROM tickets
WHERE is_deleted = false
AND status <> $1
AND synced_at < NOW() - INTERVAL '${STALE_DAYS} days'
ORDER BY synced_at ASC
LIMIT $2
`;
const stale = await postgresClient.query<{ id: string; status: number | null }>(
staleQuery,
[COMPLETE_STATUS, SCAN_LIMIT]
);
result.scanned = stale.rows.length;
logger.info(`Scanning ${result.scanned} stale-open tickets (>${STALE_DAYS}d, limit=${SCAN_LIMIT})`);
if (result.scanned === 0) {
logger.info('No stale tickets found', { duration: Date.now() - startedAt });
return result;
}
const client = getClient();
const tableName = getTableName(EntityType.TICKETS); // 'tickets'
// 2. Walk each id. Use the singleton rate limiter as an extra guard on top of
// AutotaskClient's internal limiter (defense in depth for parallel callers).
for (const row of stale.rows) {
const ticketId = Number(row.id);
const priorStatus = row.status;
try {
const entity = await autotaskRateLimiter.throttle(() =>
client.getEntityById<Record<string, any>>('Tickets', ticketId)
);
if (!entity) {
// Not in Autotask anymore -> soft-delete locally.
await postgresClient.query(
`UPDATE ${tableName}
SET is_deleted = true, deleted_at = NOW(), updated_at = NOW()
WHERE id = $1`,
[ticketId]
);
result.softDeleted += 1;
continue;
}
// 3. Upsert using the same SQL shape the webhook handler uses.
const mapped = mapAutotaskToDatabase(EntityType.TICKETS, entity);
if (!mapped) {
throw new Error(`mapAutotaskToDatabase returned null for ticket ${ticketId}`);
}
const keys = Object.keys(mapped);
const values = Object.values(mapped);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const updateClause = keys
.filter(k => k !== 'id')
.map(k => `${k} = EXCLUDED.${k}`)
.join(', ');
const upsertSql = `
INSERT INTO ${tableName} (${keys.join(', ')})
VALUES (${placeholders})
ON CONFLICT (id)
DO UPDATE SET ${updateClause}, updated_at = NOW()
`;
await postgresClient.query(upsertSql, values);
result.updated += 1;
const newStatus = (mapped.status ?? null) as number | null;
if (priorStatus !== COMPLETE_STATUS && newStatus === COMPLETE_STATUS) {
result.statusFlippedToComplete += 1;
}
} catch (err) {
result.errors += 1;
logger.warn(
`Reconcile failed for ticket ${ticketId}`,
{ ticketId },
err instanceof Error ? err : new Error(String(err))
);
}
}
logger.info(
`Reconciliation complete: scanned=${result.scanned} updated=${result.updated} flippedComplete=${result.statusFlippedToComplete} softDeleted=${result.softDeleted} errors=${result.errors}`,
{ duration: Date.now() - startedAt }
);
return result;
}