wulf-pulse/app/api/zabbix/webhook/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

188 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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` });
}