- 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
188 lines
7.5 KiB
TypeScript
188 lines
7.5 KiB
TypeScript
/**
|
||
* 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` });
|
||
}
|