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:
parent
c518eefdb2
commit
b98c67482a
40 changed files with 6223 additions and 15 deletions
188
app/api/zabbix/webhook/route.ts
Normal file
188
app/api/zabbix/webhook/route.ts
Normal 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` });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue