wulf-pulse/app/api/admin/device-link-conflicts/route.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

133 lines
4.3 KiB
TypeScript

/**
* GET /api/admin/device-link-conflicts
* Returns unresolved device-link reviews with candidate CI details, paged.
* Query params:
* limit (default 50, max 200)
* offset (default 0)
* source (filter: 'datto_rmm' | 'itglue' | ...)
*/
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
interface ReviewRow {
id: string;
detected_at: string;
xref_id: string;
source: string;
source_id: string;
hostname: string | null;
serial: string | null;
mac: string | null;
xref_company_id: string | null;
xref_company_name: string | null;
last_seen_at: string | null;
candidate_ci_ids: string[];
match_confidences: string[];
}
interface CiRow {
id: string;
reference_title: string | null;
serial_number: string | null;
rmm_device_audit_mac_address: string | null;
company_id: string | null;
company_name: string | null;
is_deleted: boolean;
}
export async function GET(request: NextRequest) {
const { error } = await requirePermission('admin', 'access');
if (error) return error;
const url = request.nextUrl;
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
const source = url.searchParams.get('source');
const params: unknown[] = [limit, offset];
let sourceFilter = '';
if (source) {
params.push(source);
sourceFilter = `AND dx.source = $${params.length}`;
}
const reviews = await postgresClient.query<ReviewRow>(
`SELECT r.id::text, r.detected_at::text, r.candidate_ci_ids::text[],
r.match_confidences,
dx.id::text AS xref_id, dx.source, dx.source_id,
dx.hostname, dx.serial, dx.mac,
dx.company_id::text AS xref_company_id,
c.company_name AS xref_company_name,
dx.last_seen_at::text
FROM device_link_review r
JOIN device_external_ids dx ON dx.id = r.device_external_id
LEFT JOIN companies c ON c.id = dx.company_id
WHERE r.resolved_at IS NULL
${sourceFilter}
ORDER BY r.detected_at DESC
LIMIT $1 OFFSET $2`,
params
);
// Bulk-fetch all candidate CI details in one query.
const allCiIds = new Set<string>();
for (const r of reviews.rows) {
for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id));
}
const ciDetails = new Map<string, CiRow>();
if (allCiIds.size > 0) {
const ciRes = await postgresClient.query<CiRow>(
`SELECT ci.id::text, ci.reference_title, ci.serial_number,
ci.rmm_device_audit_mac_address,
ci.company_id::text AS company_id,
c.company_name, COALESCE(ci.is_deleted, false) AS is_deleted
FROM configuration_items ci
LEFT JOIN companies c ON c.id = ci.company_id
WHERE ci.id = ANY($1::bigint[])`,
[Array.from(allCiIds)]
);
for (const ci of ciRes.rows) ciDetails.set(ci.id, ci);
}
const totalRes = await postgresClient.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM device_link_review r
JOIN device_external_ids dx ON dx.id = r.device_external_id
WHERE r.resolved_at IS NULL ${sourceFilter}`,
source ? [source] : []
);
const total = parseInt(totalRes.rows[0]?.count ?? '0', 10);
const items = reviews.rows.map((r) => ({
id: r.id,
detectedAt: r.detected_at,
xref: {
id: r.xref_id,
source: r.source,
sourceId: r.source_id,
hostname: r.hostname,
serial: r.serial,
mac: r.mac,
companyId: r.xref_company_id,
companyName: r.xref_company_name,
lastSeenAt: r.last_seen_at,
},
candidates: (r.candidate_ci_ids ?? []).map((ciId, i) => {
const ci = ciDetails.get(String(ciId));
return {
ciId: String(ciId),
confidence: r.match_confidences?.[i] ?? null,
hostname: ci?.reference_title ?? null,
serial: ci?.serial_number ?? null,
mac: ci?.rmm_device_audit_mac_address ?? null,
companyId: ci?.company_id ?? null,
companyName: ci?.company_name ?? null,
isDeleted: ci?.is_deleted ?? false,
};
}),
}));
return NextResponse.json({ items, total, limit, offset });
}