wulf-pulse/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/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

257 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
});
}