/** * 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( `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(); for (const r of reviews.rows) { for (const id of r.candidate_ci_ids ?? []) allCiIds.add(String(id)); } const ciDetails = new Map(); if (allCiIds.size > 0) { const ciRes = await postgresClient.query( `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 }); }