wulf-pulse/app/api/webhooks/datto-rmm/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.3 KiB
TypeScript

/**
* Datto RMM Webhook Receiver
* Generic endpoint that accepts any payload from Datto RMM and logs it raw.
* No processing logic yet — refine after inspecting real payloads.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import '@/lib/services/pipeline-steps';
import { pipelineEngine } from '@/lib/services/pipeline-engine';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
async function getDattoPingTarget(alertUid: string): Promise<string | null> {
try {
const client = getDattoRMMClient();
const target = await client.getPingAlertTarget(alertUid);
return target;
} catch {
return null;
}
}
/**
* POST /api/webhooks/datto-rmm
* Accepts any payload, stores it for inspection, returns 200.
*/
export async function POST(request: NextRequest) {
const receivedAt = new Date();
try {
const sourceIp =
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';
// Verify shared secret header
const secret = process.env.DATTO_RMM_WEBHOOK_SECRET;
if (secret) {
const provided = request.headers.get('x-datto-webhook-secret');
if (provided !== secret) {
console.warn(`[DATTO-RMM-WEBHOOK] Invalid or missing X-Datto-Webhook-Secret from ${sourceIp}`);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
// Capture all headers as a plain object
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Read raw body
const rawBody = await request.text();
// Try to parse as JSON; fall back to null
let payload: any = null;
try {
payload = JSON.parse(rawBody);
} catch {
// Not valid JSON — store raw_body only
}
console.log(
`[DATTO-RMM-WEBHOOK] Received payload from ${sourceIp} (${rawBody.length} bytes)`
);
// Store in database
await postgresClient.query(
`INSERT INTO datto_rmm_webhook_logs
(received_at, source_ip, user_agent, headers, payload, raw_body, status)
VALUES ($1, $2, $3, $4, $5, $6, 'received')`,
[
receivedAt,
sourceIp,
userAgent,
JSON.stringify(headers),
payload ? JSON.stringify(payload) : null,
rawBody || null,
]
);
// Upsert into datto_rmm_alerts if payload looks like a real alert
if (payload && typeof payload === 'object' && payload.alert_uid && !payload.alert_uid.startsWith('[')) {
const pingTarget = payload.alert_type === 'PING'
? await getDattoPingTarget(payload.alert_uid)
: null;
const isResolved = String(payload.triggered).toLowerCase() === 'false';
const str = (v: unknown) => (v && String(v).trim() !== '' ? String(v) : null);
await postgresClient.query(
`INSERT INTO datto_rmm_alerts (
alert_uid, device_uid, device_name, device_hostname, device_ip, device_os,
device_description, device_id, site_uid, site_name, site_id, platform,
priority, alert_category, alert_type, alert_message_en, last_user,
triggered, resolved, resolved_on,
device_udf1, device_udf2, device_udf3, device_udf4, device_udf5,
device_udf6, device_udf7, device_udf8, device_udf9, device_udf10,
device_udf11, device_udf12, device_udf13, device_udf14, device_udf15,
device_udf16, device_udf17, device_udf18, device_udf19, device_udf20,
device_udf21, device_udf22, device_udf23, device_udf24, device_udf25,
device_udf26, device_udf27, device_udf28, device_udf29,
ping_target,
timestamp, synced_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
$41,$42,$43,$44,$45,$46,$47,$48,$49,
$50,
$51,$52
)
ON CONFLICT (alert_uid) DO UPDATE SET
resolved = EXCLUDED.resolved,
resolved_on = CASE WHEN EXCLUDED.resolved AND datto_rmm_alerts.resolved_on IS NULL
THEN NOW() ELSE datto_rmm_alerts.resolved_on END,
triggered = EXCLUDED.triggered,
alert_message_en = COALESCE(EXCLUDED.alert_message_en, datto_rmm_alerts.alert_message_en),
ping_target = COALESCE(EXCLUDED.ping_target, datto_rmm_alerts.ping_target),
synced_at = NOW()`,
[
payload.alert_uid,
str(payload.device_uid),
str(payload.device_hostname),
str(payload.device_hostname),
str(payload.device_ip),
str(payload.device_os),
str(payload.device_description),
str(payload.device_id),
str(payload.site_uid),
str(payload.site_name),
str(payload.site_id),
str(payload.platform),
str(payload.alert_priority),
str(payload.alert_category),
str(payload.alert_type),
str(payload.alert_message_en),
str(payload.last_user),
String(payload.triggered),
isResolved,
isResolved ? receivedAt : null,
str(payload.device_udf1), str(payload.device_udf2), str(payload.device_udf3),
str(payload.device_udf4), str(payload.device_udf5), str(payload.device_udf6),
str(payload.device_udf7), str(payload.device_udf8), str(payload.device_udf9),
str(payload.device_udf10), str(payload.device_udf11), str(payload.device_udf12),
str(payload.device_udf13), str(payload.device_udf14), str(payload.device_udf15),
str(payload.device_udf16), str(payload.device_udf17), str(payload.device_udf18),
str(payload.device_udf19), str(payload.device_udf20),
str(payload.device_udf21), str(payload.device_udf22), str(payload.device_udf23),
str(payload.device_udf24), str(payload.device_udf25), str(payload.device_udf26),
str(payload.device_udf27), str(payload.device_udf28), str(payload.device_udf29),
pingTarget,
receivedAt,
receivedAt,
]
);
console.log(`[DATTO-RMM-WEBHOOK] Upserted alert ${payload.alert_uid} — resolved=${isResolved}`);
}
// Fire matching pipelines (fire-and-forget)
if (payload && typeof payload === 'object') {
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
console.error('[DATTO-RMM-WEBHOOK] Pipeline processing error:', err)
);
}
return NextResponse.json({ success: true, received_at: receivedAt.toISOString() });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[DATTO-RMM-WEBHOOK] Error storing webhook:', msg);
// Still return 200 to avoid Datto disabling the webhook
return NextResponse.json({ success: false, error: msg }, { status: 200 });
}
}
/**
* GET /api/webhooks/datto-rmm
* Health check
*/
export async function GET() {
return NextResponse.json({
status: 'active',
endpoint: '/api/webhooks/datto-rmm',
message: 'Datto RMM webhook receiver is ready',
});
}