wulf-pulse/app/api/itglue/sync-wan/route.ts
lorentz b98c67482a feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
2026-03-17 07:39:55 -04:00

151 lines
6 KiB
TypeScript

/**
* POST /api/itglue/sync-wan
* Pulls all Internet/WAN flexible assets (type 3794) from the local itg_flexible_assets
* table, parses static IPs, and upserts into itg_wan_circuits.
* Also matches Zabbix WAN hosts by IP to populate zabbix_hostid.
*/
import { NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export const maxDuration = 120;
// Extract public IPs from the raw HTML/text blob IT Glue stores
function parseStaticIps(raw: string | null): string[] {
if (!raw) return [];
// Strip HTML tags
const text = raw.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ');
// Match IPv4 addresses
const matches = text.match(/\b(\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
// Filter out private/RFC1918 ranges, subnet masks, gateways that look like masks
return [...new Set(
matches.filter(ip => {
const parts = ip.split('.').map(Number);
if (parts[0] === 10) return false;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
if (parts[0] === 192 && parts[1] === 168) return false;
if (parts[0] === 255) return false;
if (parts[0] === 0) return false;
if (ip === 'DHCP') return false;
return true;
})
)];
}
function isDecommissioned(notes: string | null): boolean {
if (!notes) return false;
const lower = notes.toLowerCase();
return lower.includes('decommission') || lower.includes('decomission') || lower.includes('retired') || lower.includes('removed');
}
export async function POST() {
try {
// Pull all IT Glue WAN flexible assets joined to org data
const assets = await postgresClient.query<{
id: number;
organization_id: number;
org_name: string;
psa_id: string | null;
provider: string | null;
link_type: string | null;
static_ips_raw: string | null;
upload_mbps: string | null;
download_mbps: string | null;
location_name: string | null;
location_city: string | null;
notes: string | null;
}>(`
SELECT
fa.id,
fa.organization_id,
io.name AS org_name,
io.psa_id,
fa.traits->>'provider' AS provider,
fa.traits->>'link-type' AS link_type,
fa.traits->>'static-ip-address-es' AS static_ips_raw,
fa.traits->>'upload-speed-mbps' AS upload_mbps,
fa.traits->>'download-speed-mbps' AS download_mbps,
fa.traits->'location-s'->'values'->0->>'name' AS location_name,
fa.traits->'location-s'->'values'->0->>'city' AS location_city,
fa.traits->>'notes' AS notes
FROM itg_flexible_assets fa
JOIN itg_organizations io ON io.id = fa.organization_id
WHERE fa.flexible_asset_type_id = 3794
`);
// Build IP → hostid map from zabbix_wan_hosts for matching
const zabbixRows = await postgresClient.query<{ hostid: string; wan_ip: string }>(
'SELECT hostid, wan_ip FROM zabbix_wan_hosts WHERE wan_ip IS NOT NULL'
);
const zabbixByIp = new Map<string, string>();
for (const r of zabbixRows.rows) {
zabbixByIp.set(r.wan_ip, r.hostid);
}
let upserted = 0;
for (const a of assets.rows) {
const staticIps = parseStaticIps(a.static_ips_raw);
const decommissioned = isDecommissioned(a.notes);
const autotaskCompanyId = a.psa_id ? Number(a.psa_id) : null;
// Try to match a Zabbix host by any of the circuit's static IPs
let zabbixHostid: string | null = null;
for (const ip of staticIps) {
if (zabbixByIp.has(ip)) { zabbixHostid = zabbixByIp.get(ip)!; break; }
}
await postgresClient.query(
`INSERT INTO itg_wan_circuits (
id, organization_id, org_name, autotask_company_id,
provider, link_type, static_ips, raw_ip_text,
upload_mbps, download_mbps, location_name, location_city,
notes, is_decommissioned, zabbix_hostid,
last_synced_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
org_name = EXCLUDED.org_name,
autotask_company_id = EXCLUDED.autotask_company_id,
provider = EXCLUDED.provider,
link_type = EXCLUDED.link_type,
static_ips = EXCLUDED.static_ips,
raw_ip_text = EXCLUDED.raw_ip_text,
upload_mbps = EXCLUDED.upload_mbps,
download_mbps = EXCLUDED.download_mbps,
location_name = EXCLUDED.location_name,
location_city = EXCLUDED.location_city,
notes = EXCLUDED.notes,
is_decommissioned = EXCLUDED.is_decommissioned,
zabbix_hostid = EXCLUDED.zabbix_hostid,
last_synced_at = NOW(),
updated_at = NOW()`,
[
a.id, a.organization_id, a.org_name, autotaskCompanyId,
a.provider, a.link_type, staticIps, a.static_ips_raw,
a.upload_mbps ? Number(a.upload_mbps) : null,
a.download_mbps ? Number(a.download_mbps) : null,
a.location_name, a.location_city,
a.notes, decommissioned, zabbixHostid,
]
);
upserted++;
}
// Summary stats
const stats = await postgresClient.query<{
total: string; with_ip: string; matched_zabbix: string; decommissioned: string; no_itg_org_link: string;
}>(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE array_length(static_ips,1) > 0) AS with_ip,
COUNT(*) FILTER (WHERE zabbix_hostid IS NOT NULL) AS matched_zabbix,
COUNT(*) FILTER (WHERE is_decommissioned) AS decommissioned,
COUNT(*) FILTER (WHERE autotask_company_id IS NULL) AS no_itg_org_link
FROM itg_wan_circuits
`);
return NextResponse.json({ upserted, ...stats.rows[0] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}