- 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>
257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
/**
|
||
* POST /api/analyzer/tickets/:ticketNumber/analyze-bundle
|
||
*
|
||
* Body: { linkedTicketNumbers: string[], includeItglueContext?, reportTitle?, confirmedCost? }
|
||
*
|
||
* Behavior:
|
||
* 1. Validate the master + each linked ticket exists in the local mirror.
|
||
* 2. For each ticket: idempotency-check via content hash; if a fresh
|
||
* analysis exists, collect its id; otherwise queue an analyzer job.
|
||
* 3. Cost-guard the *new* work only (already-complete analyses don't add
|
||
* cost).
|
||
* 4. Create an analyzer_aggregate_reports row populated with
|
||
* expected_ticket_numbers + triggered_by_ticket_number. If everything
|
||
* was already complete, transition straight to 'pending' and fire
|
||
* runAggregateReport. Otherwise the row sits in 'pending_analyses'
|
||
* until the worker chain-trigger flips it once all jobs land.
|
||
*
|
||
* Response:
|
||
* { aggregateReportId, ticketCount, queuedJobIds, alreadyCompleteAnalysisIds, status }
|
||
*/
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth-utils';
|
||
import postgresClient from '@/lib/services/postgres-client';
|
||
import {
|
||
loadTicketBundle,
|
||
TicketNotFoundError,
|
||
} from '@/lib/services/analyzer/data-access';
|
||
import { preprocessTicket } from '@/lib/services/analyzer/preprocessor';
|
||
import {
|
||
findExistingAnalysisByContentHash,
|
||
queueJob,
|
||
} from '@/lib/services/analyzer/persistence';
|
||
import {
|
||
createAggregateReport,
|
||
runAggregateReport,
|
||
} from '@/lib/services/analyzer/aggregate-persistence';
|
||
import {
|
||
estimateAggregateReportCost,
|
||
evaluateCost,
|
||
recordCostAuditDecision,
|
||
} from '@/lib/services/analyzer/cost-guard';
|
||
import { BundleAnalyzeRequest } from '@/lib/types/analyzer';
|
||
// Side-effect import: ensure the worker self-starts so queued jobs run.
|
||
import '@/lib/services/analyzer/worker';
|
||
|
||
const MAX_BUNDLE_SIZE = 25;
|
||
|
||
/**
|
||
* Pessimistic per-ticket cost. Anthropic path runs Sonnet (+ optional Opus);
|
||
* OpenRouter path runs DeepSeek V4 Pro (+ optional R1) at ~7-10× lower rate.
|
||
*/
|
||
const PER_TICKET_COST_USD: Record<'anthropic' | 'openrouter', number> = {
|
||
anthropic: 0.15,
|
||
openrouter: 0.02,
|
||
};
|
||
|
||
export async function POST(
|
||
request: NextRequest,
|
||
{ params }: { params: Promise<{ ticketNumber: string }> }
|
||
) {
|
||
const { session, error } = await requireAuth();
|
||
if (error) return error;
|
||
|
||
const { ticketNumber: masterTicketNumber } = await params;
|
||
|
||
const body = await request.json().catch(() => ({}));
|
||
const parsed = BundleAnalyzeRequest.safeParse(body);
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ error: 'Invalid request body', details: parsed.error.issues },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const {
|
||
linkedTicketNumbers,
|
||
includeItglueContext,
|
||
reportTitle,
|
||
confirmedCost,
|
||
provider,
|
||
} = parsed.data;
|
||
|
||
// Build the unique ordered set: master first, then linked (deduped, master removed).
|
||
const linkedSet = new Set(linkedTicketNumbers);
|
||
linkedSet.delete(masterTicketNumber);
|
||
const allTicketNumbers = [masterTicketNumber, ...Array.from(linkedSet)];
|
||
|
||
if (allTicketNumbers.length < 2) {
|
||
return NextResponse.json(
|
||
{
|
||
error:
|
||
'Bundle requires at least one linked ticket besides the master. Use /analyze for single-ticket runs.',
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (allTicketNumbers.length > MAX_BUNDLE_SIZE) {
|
||
return NextResponse.json(
|
||
{
|
||
error: `Bundle size ${allTicketNumbers.length} exceeds cap ${MAX_BUNDLE_SIZE}.`,
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// Verify every ticket exists locally in one query.
|
||
const lookup = await postgresClient.query<{ ticket_number: string }>(
|
||
`SELECT ticket_number FROM tickets
|
||
WHERE ticket_number = ANY($1::text[])
|
||
AND COALESCE(is_deleted, false) = false`,
|
||
[allTicketNumbers]
|
||
);
|
||
const present = new Set(lookup.rows.map((r) => r.ticket_number));
|
||
const missing = allTicketNumbers.filter((n) => !present.has(n));
|
||
if (missing.length > 0) {
|
||
return NextResponse.json(
|
||
{ error: 'Some tickets not found in local mirror', missing },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
|
||
const userId = (session?.user as { id: string } | undefined)?.id ?? null;
|
||
|
||
// Per-ticket idempotency check + queue plan.
|
||
const queuedJobIds: string[] = [];
|
||
const alreadyCompleteAnalysisIds: string[] = [];
|
||
const ticketsNeedingAnalysis: string[] = [];
|
||
|
||
for (const tn of allTicketNumbers) {
|
||
let bundle;
|
||
try {
|
||
bundle = await loadTicketBundle(tn);
|
||
} catch (err) {
|
||
if (err instanceof TicketNotFoundError) {
|
||
// Shouldn't happen — we just verified existence — but be defensive.
|
||
return NextResponse.json(
|
||
{ error: `Ticket ${tn} disappeared between checks` },
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
console.error(`[analyze-bundle] data-access error for ${tn}:`, err);
|
||
return NextResponse.json(
|
||
{ error: 'Failed to load ticket', message: tn },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
const pre = preprocessTicket(bundle);
|
||
const existing = await findExistingAnalysisByContentHash(
|
||
tn,
|
||
pre.content_hash,
|
||
provider
|
||
);
|
||
if (existing) {
|
||
alreadyCompleteAnalysisIds.push(existing.id);
|
||
} else {
|
||
ticketsNeedingAnalysis.push(tn);
|
||
}
|
||
}
|
||
|
||
// Cost-guard: only the new per-ticket work + the aggregate-reduce step.
|
||
const aggregateCost = estimateAggregateReportCost({
|
||
ticketCount: allTicketNumbers.length,
|
||
includeItglueContext,
|
||
});
|
||
const newPerTicketCost =
|
||
ticketsNeedingAnalysis.length * PER_TICKET_COST_USD[provider];
|
||
const estimatedCost =
|
||
Math.round((newPerTicketCost + aggregateCost) * 10_000) / 10_000;
|
||
|
||
const evaluation = await evaluateCost({
|
||
userId,
|
||
estimatedCost,
|
||
confirmedCost,
|
||
});
|
||
await recordCostAuditDecision({
|
||
userId,
|
||
action: 'analyze_bundle',
|
||
evaluation,
|
||
context: {
|
||
masterTicketNumber,
|
||
ticketCount: allTicketNumbers.length,
|
||
newPerTicketCount: ticketsNeedingAnalysis.length,
|
||
includeItglueContext,
|
||
},
|
||
});
|
||
if (evaluation.decision === 'blocked') {
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Daily cost limit reached',
|
||
message: evaluation.decisionReason,
|
||
estimatedCost: evaluation.estimatedCost,
|
||
dailySpendBefore: evaluation.dailySpendBefore,
|
||
},
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
if (evaluation.decision === 'requires_confirmation') {
|
||
return NextResponse.json(
|
||
{
|
||
error: 'Confirmation required',
|
||
message: evaluation.decisionReason,
|
||
estimatedCost: evaluation.estimatedCost,
|
||
dailySpendBefore: evaluation.dailySpendBefore,
|
||
requiresConfirmation: true,
|
||
retryWith: { confirmedCost: true },
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// Queue jobs for the missing tickets.
|
||
for (const tn of ticketsNeedingAnalysis) {
|
||
const job = await queueJob({
|
||
ticket_number: tn,
|
||
queued_by_user_id: userId,
|
||
provider,
|
||
});
|
||
queuedJobIds.push(job.id);
|
||
}
|
||
|
||
// Create the aggregate report row. Bundle mode (expectedTicketNumbers set)
|
||
// means status starts as 'pending_analyses' if any jobs were queued, or as
|
||
// 'pending' if everything was already complete and we can run immediately.
|
||
const allAlreadyComplete = ticketsNeedingAnalysis.length === 0;
|
||
const created = await createAggregateReport({
|
||
generatedByUserId: userId,
|
||
filterCriteria: {
|
||
mode: 'bundle',
|
||
masterTicketNumber,
|
||
linkedTicketNumbers: Array.from(linkedSet),
|
||
provider,
|
||
},
|
||
analysisIds: alreadyCompleteAnalysisIds,
|
||
ticketCount: allTicketNumbers.length,
|
||
includeItglueContext,
|
||
reportTitle: reportTitle ?? null,
|
||
expectedTicketNumbers: allAlreadyComplete ? undefined : allTicketNumbers,
|
||
triggeredByTicketNumber: masterTicketNumber,
|
||
});
|
||
|
||
if (allAlreadyComplete) {
|
||
void runAggregateReport(created.id).catch((err) => {
|
||
console.error('[analyze-bundle] background runner threw:', err);
|
||
});
|
||
}
|
||
|
||
return NextResponse.json({
|
||
aggregateReportId: created.id,
|
||
ticketCount: allTicketNumbers.length,
|
||
queuedJobIds,
|
||
alreadyCompleteAnalysisIds,
|
||
status: allAlreadyComplete ? 'pending' : 'pending_analyses',
|
||
estimatedCost: evaluation.estimatedCost,
|
||
softWarn: evaluation.softWarn,
|
||
});
|
||
}
|