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).
This commit is contained in:
parent
1a488e5d1d
commit
51f0b32cb3
2 changed files with 182 additions and 0 deletions
31
app/api/sync/reconcile-tickets/route.ts
Normal file
31
app/api/sync/reconcile-tickets/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Ticket Reconciliation API Endpoint
|
||||
* POST /api/sync/reconcile-tickets - Trigger a stale-ticket reconciliation pass.
|
||||
* Public per middleware.ts (matches /api/sync/incremental). Fire-and-forget.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { reconcileStaleTickets } from '@/lib/services/ticket-reconciliation-service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const triggeredBy = body.triggeredBy || 'api';
|
||||
|
||||
// Non-blocking — same pattern as /api/sync/incremental.
|
||||
reconcileStaleTickets().catch((error) => {
|
||||
console.error('[RECONCILE] Ticket reconciliation failed:', error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Ticket reconciliation started',
|
||||
triggeredBy,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start ticket reconciliation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to start ticket reconciliation' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
lib/services/ticket-reconciliation-service.ts
Normal file
151
lib/services/ticket-reconciliation-service.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue