- 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
151 lines
6 KiB
TypeScript
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(/ /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 });
|
|
}
|
|
}
|