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
This commit is contained in:
lorentz 2026-03-17 07:39:55 -04:00
parent c518eefdb2
commit b98c67482a
40 changed files with 6223 additions and 15 deletions

View file

@ -0,0 +1,188 @@
/**
* POST /api/zabbix/webhook
* Receives Zabbix alert/recovery notifications and writes them to zabbix_events.
* Enriches each event with company/site context from the local zabbix_wan_hosts cache.
*
* Expected JSON payload (sent by the Zabbix webhook media type script):
* {
* event_id: string -- {EVENT.ID}
* event_name: string -- {EVENT.NAME}
* event_value: string -- "1" = PROBLEM, "0" = RESOLVED
* event_severity: string -- numeric severity "0""5"
* event_clock: string -- Unix timestamp string
* trigger_id: string -- {TRIGGER.ID}
* host_id: string -- {HOST.ID}
* host_name: string -- {HOST.HOST} (technical name)
* r_event_id?: string -- {EVENT.RECOVERY.ID} (only on recovery)
* r_clock?: string -- {EVENT.RECOVERY.DATE} as unix ts (only on recovery)
* }
*
* Auth: Bearer token in Authorization header, matched against ZABBIX_WEBHOOK_SECRET env var.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function POST(request: NextRequest) {
// Optional shared secret — skip check if not configured
const secret = process.env.ZABBIX_WEBHOOK_SECRET;
if (secret) {
const auth = request.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
if (token !== secret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
let body: Record<string, string>;
try {
const raw = await request.text();
console.log('[ZABBIX-WEBHOOK] Incoming request — auth:', request.headers.get('authorization') ? 'present' : 'none', '— body:', raw.substring(0, 500));
body = JSON.parse(raw);
} catch {
console.log('[ZABBIX-WEBHOOK] Failed to parse JSON');
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const {
event_id,
event_name,
event_value,
event_severity,
event_clock,
trigger_id,
host_id,
r_event_id,
r_clock,
} = body;
if (!event_id || !host_id || !event_clock) {
return NextResponse.json({ error: 'Missing required fields: event_id, host_id, event_clock' }, { status: 400 });
}
try {
// Enrich with local host context
const hostRow = await postgresClient.query<{
display_name: string;
wan_ip: string | null;
autotask_company_id: number | null;
autotask_company_name: string | null;
rmm_site_uid: string | null;
isp_name: string | null;
}>(
'SELECT display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts WHERE hostid = $1',
[host_id]
);
const host = hostRow.rows[0] ?? null;
const toInt = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
const toTs = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; };
const isMacro = (v: string | undefined) => !v || v.startsWith('{');
const isResolved = event_value === '0';
const clockTs = toTs(event_clock) ?? new Date();
// {EVENT.RECOVERY.CLOCK} often doesn't resolve in webhook params — fall back to NOW() for recoveries
const rClockTs = toTs(r_clock) ?? (isResolved ? new Date() : null);
const duration = rClockTs ? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000) : null;
// {EVENT.RECOVERY.ID} also may not resolve — use event_id as marker so the row is flagged resolved
const rEventId = !isMacro(r_event_id) && r_event_id !== '0'
? r_event_id
: (isResolved ? event_id : null);
await postgresClient.query(
`INSERT INTO zabbix_events (
eventid, objectid, name, severity, clock,
r_eventid, r_clock, duration_seconds,
hostid, host_name, wan_ip,
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
ON CONFLICT (eventid) DO UPDATE SET
name = EXCLUDED.name,
r_eventid = COALESCE(EXCLUDED.r_eventid, zabbix_events.r_eventid),
r_clock = COALESCE(EXCLUDED.r_clock, zabbix_events.r_clock),
duration_seconds = COALESCE(EXCLUDED.duration_seconds, zabbix_events.duration_seconds),
host_name = EXCLUDED.host_name,
wan_ip = EXCLUDED.wan_ip,
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
rmm_site_uid = EXCLUDED.rmm_site_uid,
isp_name = EXCLUDED.isp_name,
last_synced_at = NOW()`,
[
event_id,
trigger_id ?? null,
event_name ?? null,
toInt(event_severity),
clockTs,
rEventId,
rClockTs,
duration,
host_id,
host?.display_name ?? body.host_name ?? null,
host?.wan_ip ?? null,
host?.autotask_company_id ?? null,
host?.autotask_company_name ?? null,
host?.rmm_site_uid ?? null,
host?.isp_name ?? null,
]
);
return NextResponse.json({ ok: true, event_id, resolved: isResolved });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[ZABBIX-WEBHOOK]', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/**
* GET /api/zabbix/webhook
* Returns the Zabbix media type script and parameter config ready to paste.
*/
export async function GET() {
const baseUrl = process.env.WEBHOOK_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://your-pulse-url';
const secret = process.env.ZABBIX_WEBHOOK_SECRET ?? '';
const script = `// Pulse WAN Correlation Webhook
var params = JSON.parse(value);
var req = new HttpRequest();
req.addHeader('Content-Type: application/json');
${secret ? "req.addHeader('Authorization: Bearer ' + params.webhook_secret);" : '// No auth configured — set ZABBIX_WEBHOOK_SECRET env var to enable'}
var payload = JSON.stringify({
event_id: params.event_id,
event_name: params.event_name,
event_value: params.event_value,
event_severity: params.event_severity,
event_clock: params.event_clock,
trigger_id: params.trigger_id,
host_id: params.host_id,
host_name: params.host_name,
r_event_id: params.r_event_id,
r_clock: params.r_clock
});
var response = req.post(params.webhook_url, payload);
if (req.getStatus() !== 200) {
throw 'Pulse webhook failed: HTTP ' + req.getStatus() + ' — ' + response;
}
return 'OK';`;
const parameters = [
{ name: 'webhook_url', value: `${baseUrl}/api/zabbix/webhook` },
{ name: 'webhook_secret', value: secret || '(set ZABBIX_WEBHOOK_SECRET env var)' },
{ name: 'event_id', value: '{EVENT.ID}' },
{ name: 'event_name', value: '{EVENT.NAME}' },
{ name: 'event_value', value: '{EVENT.VALUE}' },
{ name: 'event_severity', value: '{EVENT.SEVERITY.NUM}' },
{ name: 'event_clock', value: '{EVENT.CLOCK}' },
{ name: 'trigger_id', value: '{TRIGGER.ID}' },
{ name: 'host_id', value: '{HOST.ID}' },
{ name: 'host_name', value: '{HOST.HOST}' },
{ name: 'r_event_id', value: '{EVENT.RECOVERY.ID}' },
{ name: 'r_clock', value: '{EVENT.RECOVERY.CLOCK}' },
];
return NextResponse.json({ script, parameters, webhook_url: `${baseUrl}/api/zabbix/webhook` });
}