From 966376e6b6f988b2dfb0ab2e935c9153ce591ee8 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 11:21:10 -0400 Subject: [PATCH 1/8] fix(analyzer): import worker from analyze route to trigger auto-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker.ts has a self-init side effect on module load, but nothing in the shipped code imported it — so jobs queued but no worker ran. Adding a side-effect import to the analyze route handler; Next.js eagerly loads route modules at boot to build the routing manifest, so this runs once per server process. Confirmed live: [ANALYZER-WORKER] starting log line fires on container start. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts index c58bcbe..0b7309d 100644 --- a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts +++ b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts @@ -23,6 +23,10 @@ import { findExistingAnalysisByContentHash, queueJob, } from '@/lib/services/analyzer/persistence'; +// Side-effect import: triggers the worker's auto-start at server boot. Next.js +// eagerly loads route handler modules to build the routing manifest, so this +// import runs once per server process — same trick sync-scheduler relies on. +import '@/lib/services/analyzer/worker'; export async function POST( request: NextRequest, From b20c94ea1a52cc6435e146e17500b1c80c3dd5bb Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 13:25:16 -0400 Subject: [PATCH 2/8] feat(analyzer): browse-tickets page + analysis-view typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /analyzer/tickets — period chips (today/yesterday/this+last week/30d/60d/all), client + issue-type Selects, debounced search, per-row Analyze/Re-analyze plus View shortcut when an analysis already exists. - API: /api/analyzer/tickets/list (period/companyId/issueType/search, paginated via COUNT(*) OVER) and /filter-options (companies that actually have tickets, active issue types). - ProseText helper in analysis-view splits on blank lines and renders each chunk with leading-7 — Summary, Next Step, rationale, and Post-Resolution now have proper paragraph rhythm. Next Step card re-styled with bg-primary/5 tint, ArrowRight icon, and an indented rationale block. - Top-level "Analyzer" nav menu (Browse Tickets + Needs Review). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/analyzer/tickets/page.tsx | 487 ++++++++++++++++++ .../analyzer/tickets/filter-options/route.ts | 55 ++ app/api/analyzer/tickets/list/route.ts | 177 +++++++ components/analyzer/analysis-view.tsx | 79 ++- components/navigation/app-navigation.tsx | 20 + .../wulf-pulse-ticket-analyzer-build-notes.md | 80 +++ 6 files changed, 879 insertions(+), 19 deletions(-) create mode 100644 app/analyzer/tickets/page.tsx create mode 100644 app/api/analyzer/tickets/filter-options/route.ts create mode 100644 app/api/analyzer/tickets/list/route.ts diff --git a/app/analyzer/tickets/page.tsx b/app/analyzer/tickets/page.tsx new file mode 100644 index 0000000..31511f5 --- /dev/null +++ b/app/analyzer/tickets/page.tsx @@ -0,0 +1,487 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Search, + Sparkles, + CheckCircle2, + Filter, + X, +} from 'lucide-react'; +import { AnalyzeButton } from '@/components/analyzer/analyze-button'; + +type Period = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'last_30d' + | 'last_60d' + | 'all'; + +interface PeriodOption { + value: Period; + label: string; +} + +const PERIOD_OPTIONS: PeriodOption[] = [ + { value: 'today', label: 'Today' }, + { value: 'yesterday', label: 'Yesterday' }, + { value: 'this_week', label: 'This week' }, + { value: 'last_week', label: 'Last week' }, + { value: 'last_30d', label: 'Last 30 days' }, + { value: 'last_60d', label: 'Last 60 days' }, + { value: 'all', label: 'All time' }, +]; + +interface TicketRow { + ticketNumber: string; + title: string | null; + companyName: string | null; + issueTypeLabel: string | null; + statusLabel: string | null; + priorityLabel: string | null; + lastActivityDate: string | null; + createDate: string | null; + latestAnalysisId: string | null; + latestAnalysisVersion: number | null; +} + +interface FilterOptions { + companies: { id: string; name: string }[]; + issueTypes: { value: number; label: string }[]; +} + +const PAGE_SIZE = 50; + +const ALL_COMPANIES = '__all_companies__'; +const ALL_ISSUE_TYPES = '__all_issue_types__'; + +function formatRelative(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.round(diffMs / 60000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.round(diffHr / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString(); +} + +export default function AnalyzerBrowseTicketsPage() { + const [period, setPeriod] = useState('last_30d'); + const [companyId, setCompanyId] = useState(ALL_COMPANIES); + const [issueType, setIssueType] = useState(ALL_ISSUE_TYPES); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + + const [tickets, setTickets] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [filterOptions, setFilterOptions] = useState(null); + + // Debounce search input → applied search + useEffect(() => { + const t = setTimeout(() => { + setSearch(searchInput.trim()); + setPage(0); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + // Reset page when filters change + useEffect(() => { + setPage(0); + }, [period, companyId, issueType]); + + // Load filter options once + useEffect(() => { + let cancelled = false; + fetch('/api/analyzer/tickets/filter-options') + .then((r) => (r.ok ? r.json() : Promise.reject(r))) + .then((data: FilterOptions) => { + if (!cancelled) setFilterOptions(data); + }) + .catch(() => { + if (!cancelled) + setFilterOptions({ companies: [], issueTypes: [] }); + }); + return () => { + cancelled = true; + }; + }, []); + + const fetchTickets = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + period, + limit: String(PAGE_SIZE), + offset: String(page * PAGE_SIZE), + }); + if (companyId !== ALL_COMPANIES) params.set('companyId', companyId); + if (issueType !== ALL_ISSUE_TYPES) params.set('issueType', issueType); + if (search) params.set('search', search); + const res = await fetch(`/api/analyzer/tickets/list?${params.toString()}`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new Error(data.message ?? data.error ?? `Failed: ${res.status}`); + } + const data = (await res.json()) as { + tickets: TicketRow[]; + total: number; + }; + setTickets(data.tickets); + setTotal(data.total); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + setError(msg); + setTickets([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [period, companyId, issueType, search, page]); + + useEffect(() => { + void fetchTickets(); + }, [fetchTickets]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const activeFilterCount = useMemo(() => { + let n = 0; + if (period !== 'last_30d') n++; + if (companyId !== ALL_COMPANIES) n++; + if (issueType !== ALL_ISSUE_TYPES) n++; + if (search) n++; + return n; + }, [period, companyId, issueType, search]); + + function clearFilters() { + setPeriod('last_30d'); + setCompanyId(ALL_COMPANIES); + setIssueType(ALL_ISSUE_TYPES); + setSearchInput(''); + setSearch(''); + } + + const companyName = useMemo(() => { + if (companyId === ALL_COMPANIES) return null; + return ( + filterOptions?.companies.find((c) => c.id === companyId)?.name ?? null + ); + }, [companyId, filterOptions]); + + return ( +
+
+
+

+ Browse Tickets to Analyze +

+

+ Filter by activity window, client, or issue type — click Analyze to + run the AI pipeline against any ticket. +

+
+ +
+ + {/* Filter bar */} + + +
+ + + Filters + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + {activeFilterCount > 0 && ( + + )} +
+
+ + {/* Period chips */} +
+ +
+ {PERIOD_OPTIONS.map((p) => ( + + ))} +
+
+ + {/* Other filters in a grid */} +
+
+ + +
+ +
+ + +
+ +
+ +
+ + setSearchInput(e.target.value)} + /> +
+
+
+
+
+ + {/* Results */} + + +
+ + {loading + ? 'Loading…' + : total === 0 + ? 'No tickets match' + : total === 1 + ? '1 ticket' + : `${total.toLocaleString()} tickets`} + {companyName && total > 0 && ( + + · {companyName} + + )} + + {total > PAGE_SIZE && ( +
+ + Page {page + 1} of {totalPages} + + + +
+ )} +
+
+ + {error ? ( +
{error}
+ ) : tickets.length === 0 && !loading ? ( +
+ +

No tickets match the current filters.

+

+ Try widening the period or clearing some filters. +

+
+ ) : ( + + + + Ticket + Title + Client + Issue type + Status + Last activity + + Actions + + + + + {tickets.map((t) => ( + + + + {t.ticketNumber} + + + +
+ + {t.title ?? No title} + + {t.latestAnalysisId && ( + + + v{t.latestAnalysisVersion} + + )} +
+
+ + {t.companyName ?? } + + + {t.issueTypeLabel ?? } + + + {t.statusLabel ? ( + {t.statusLabel} + ) : ( + + )} + + + {formatRelative(t.lastActivityDate)} + + +
+ {t.latestAnalysisId && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/app/api/analyzer/tickets/filter-options/route.ts b/app/api/analyzer/tickets/filter-options/route.ts new file mode 100644 index 0000000..e12c641 --- /dev/null +++ b/app/api/analyzer/tickets/filter-options/route.ts @@ -0,0 +1,55 @@ +/** + * GET /api/analyzer/tickets/filter-options + * + * Returns the dropdown source data for the analyzer ticket browser. + * Companies are limited to those that have at least one non-deleted ticket + * (255 → typically ~150 with tickets) so the dropdown isn't padded with + * dormant accounts. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface CompanyRow { + id: string; + company_name: string; +} +interface IssueTypeRow { + value: number; + label: string; +} + +export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + + const [companies, issueTypes] = await Promise.all([ + postgresClient.query( + `SELECT c.id::text AS id, c.company_name + FROM companies c + WHERE c.is_active = true + AND c.is_deleted = false + AND EXISTS ( + SELECT 1 FROM tickets t + WHERE t.company_id = c.id AND t.is_deleted = false + ) + ORDER BY c.company_name` + ), + postgresClient.query( + `SELECT value, label + FROM issue_types + WHERE is_active = true + AND is_deleted = false + ORDER BY sort_order NULLS LAST, label` + ), + ]); + + return NextResponse.json({ + companies: companies.rows.map((r) => ({ + id: r.id, + name: r.company_name, + })), + issueTypes: issueTypes.rows, + }); +} diff --git a/app/api/analyzer/tickets/list/route.ts b/app/api/analyzer/tickets/list/route.ts new file mode 100644 index 0000000..985d31a --- /dev/null +++ b/app/api/analyzer/tickets/list/route.ts @@ -0,0 +1,177 @@ +/** + * GET /api/analyzer/tickets/list + * + * Browse view backing the /analyzer/tickets page. Filters tickets by + * `last_activity_date` (the most useful axis for "what's worth analyzing + * right now") plus optional company / issue type / free-text search. + * + * Query params: + * period one of today | yesterday | this_week | last_week | last_30d | last_60d | all + * companyId numeric companies.id, optional + * issueType numeric issue_types.value, optional + * search substring match against ticket_number or title + * limit default 50, capped at 200 + * offset default 0 + * + * Returns: + * { tickets: TicketRow[], total: number } + * + * Each row carries `latestAnalysisId` if the ticket already has a complete + * analysis, so the UI can offer "View analysis" alongside "Analyze". + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +type Period = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'last_30d' + | 'last_60d' + | 'all'; + +const ALLOWED_PERIODS: ReadonlySet = new Set([ + 'today', + 'yesterday', + 'this_week', + 'last_week', + 'last_30d', + 'last_60d', + 'all', +]); + +/** + * Returns the SQL fragment for the date predicate. Uses Postgres-side NOW() + * so "today" reflects the database server's clock — this is an internal tool + * and the DB and app process share the same clock. + * + * Returns the predicate string with no parameters — these date expressions + * are constants from the API perspective, computed in Postgres. + */ +function periodPredicate(period: Period): string { + switch (period) { + case 'today': + return `t.last_activity_date >= date_trunc('day', NOW())`; + case 'yesterday': + return `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' + AND t.last_activity_date < date_trunc('day', NOW())`; + case 'this_week': + return `t.last_activity_date >= date_trunc('week', NOW())`; + case 'last_week': + return `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' + AND t.last_activity_date < date_trunc('week', NOW())`; + case 'last_30d': + return `t.last_activity_date >= NOW() - INTERVAL '30 days'`; + case 'last_60d': + return `t.last_activity_date >= NOW() - INTERVAL '60 days'`; + case 'all': + return `TRUE`; + } +} + +interface TicketRow { + ticket_number: string; + title: string | null; + company_name: string | null; + issue_type_label: string | null; + status_label: string | null; + priority_label: string | null; + last_activity_date: Date | null; + create_date: Date | null; + latest_analysis_id: string | null; + latest_analysis_version: number | null; + total_count: string; +} + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const url = new URL(request.url); + const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period; + const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d'; + const companyIdRaw = url.searchParams.get('companyId'); + const companyId = companyIdRaw ? Number(companyIdRaw) : null; + const issueTypeRaw = url.searchParams.get('issueType'); + const issueType = issueTypeRaw ? Number(issueTypeRaw) : null; + const search = (url.searchParams.get('search') ?? '').trim() || null; + const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200); + const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0); + + const sql = ` + WITH filtered AS ( + SELECT t.id, t.ticket_number, t.title, + t.company_id, t.issue_type, t.status, t.priority, + t.last_activity_date, t.create_date + FROM tickets t + WHERE t.is_deleted = false + AND ${periodPredicate(period)} + AND ($1::bigint IS NULL OR t.company_id = $1::bigint) + AND ($2::int IS NULL OR t.issue_type = $2::int) + AND ($3::text IS NULL OR ( + t.ticket_number ILIKE '%' || $3::text || '%' + OR t.title ILIKE '%' || $3::text || '%' + )) + ) + SELECT f.ticket_number, + f.title, + c.company_name, + it.label AS issue_type_label, + s.label AS status_label, + pr.label AS priority_label, + f.last_activity_date, + f.create_date, + latest.id::text AS latest_analysis_id, + latest.analysis_version AS latest_analysis_version, + COUNT(*) OVER () AS total_count + FROM filtered f + LEFT JOIN companies c ON c.id = f.company_id + LEFT JOIN issue_types it ON it.value = f.issue_type + LEFT JOIN statuses s ON s.value = f.status + LEFT JOIN priorities pr ON pr.value = f.priority + LEFT JOIN LATERAL ( + SELECT aa.id, aa.analysis_version + FROM analyzer_analyses aa + WHERE aa.ticket_number = f.ticket_number + AND aa.status = 'complete' + ORDER BY aa.analysis_version DESC + LIMIT 1 + ) latest ON TRUE + ORDER BY f.last_activity_date DESC NULLS LAST + LIMIT $4 OFFSET $5 + `; + + const res = await postgresClient.query(sql, [ + companyId, + issueType, + search, + limit, + offset, + ]); + + const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; + + return NextResponse.json({ + period, + total, + limit, + offset, + tickets: res.rows.map((r) => ({ + ticketNumber: r.ticket_number, + title: r.title, + companyName: r.company_name, + issueTypeLabel: r.issue_type_label, + statusLabel: r.status_label, + priorityLabel: r.priority_label, + lastActivityDate: r.last_activity_date + ? r.last_activity_date.toISOString() + : null, + createDate: r.create_date ? r.create_date.toISOString() : null, + latestAnalysisId: r.latest_analysis_id, + latestAnalysisVersion: r.latest_analysis_version, + })), + }); +} diff --git a/components/analyzer/analysis-view.tsx b/components/analyzer/analysis-view.tsx index b4ad5f5..46c52d3 100644 --- a/components/analyzer/analysis-view.tsx +++ b/components/analyzer/analysis-view.tsx @@ -11,7 +11,13 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible'; import { Separator } from '@/components/ui/separator'; -import { ChevronRight, ChevronDown, ExternalLink, AlertTriangle } from 'lucide-react'; +import { + ChevronRight, + ChevronDown, + ExternalLink, + AlertTriangle, + ArrowRight, +} from 'lucide-react'; import { ShareModal } from './share-modal'; import { AnalyzeButton } from './analyze-button'; import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer'; @@ -38,6 +44,29 @@ const SEVERITY_TONE: Record = { low: 'border-blue-500 bg-blue-500/5', }; +function ProseText({ + text, + className = '', +}: { + text: string; + className?: string; +}) { + const paragraphs = text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + if (paragraphs.length === 0) return null; + return ( +
+ {paragraphs.map((p, i) => ( +

+ {p} +

+ ))} +
+ ); +} + function ConfidenceBadge({ score }: { score: number | null }) { if (score === null) return null; const pct = Math.round(score * 100); @@ -135,42 +164,51 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { {a.summary && ( - Summary + + Summary + -

- {a.summary} -

+
)} {/* 3. Next step */} {a.nextStep && ( - + - - Next step + + + Recommended Next Step -

{a.nextStep}

+ {a.nextStepRationale && ( - + - -

- {a.nextStepRationale} -

+
)} @@ -308,12 +346,15 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { {a.postResolutionAnalysis && ( - Post-resolution analysis + + Post-Resolution Analysis + -

- {a.postResolutionAnalysis} -

+
)} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index 6ad5993..bb63f0d 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -29,6 +29,8 @@ import { SlidersHorizontal, GitCompare, Brain, + Search, + AlertTriangle, } from 'lucide-react'; import { NavigationMenu, @@ -106,6 +108,24 @@ const navigationItems: NavItem[] = [ }, ], }, + { + title: 'Analyzer', + icon: Sparkles, + children: [ + { + title: 'Browse Tickets', + href: '/analyzer/tickets', + icon: Search, + description: 'Filter tickets by period, client, or issue type — pick one to analyze', + }, + { + title: 'Needs Review', + href: '/analyzer/queue', + icon: AlertTriangle, + description: 'Analyses flagged for human review (low confidence or cost-ceiling skipped Opus)', + }, + ], + }, { title: 'Admin', icon: Activity, diff --git a/docs/wulf-pulse-ticket-analyzer-build-notes.md b/docs/wulf-pulse-ticket-analyzer-build-notes.md index ee7f254..0d2ec94 100644 --- a/docs/wulf-pulse-ticket-analyzer-build-notes.md +++ b/docs/wulf-pulse-ticket-analyzer-build-notes.md @@ -414,6 +414,85 @@ This file is updated after each phase ships. --- +## Phase 9 — Ticket browser + analysis-view formatting + +Reactive to first-use feedback: the analysis page Summary / Next Step +text was bare and dense, and there was no way to discover tickets to +analyze without typing the URL. + +**Delivered** + +- `` helper inside `analysis-view.tsx` — splits text on blank + lines, renders each chunk as a separate `

` with `leading-7` and + `whitespace-pre-line`. Applied to Summary, Next Step, Next Step + rationale, and Post-Resolution Analysis. Single-paragraph text still + renders cleanly. +- Summary + Post-Resolution headers got an uppercase tracking-wide + treatment to act as section dividers, and body text bumped to + `text-base text-foreground` so it reads as a finding rather than a + caption. +- Next Step card now has a subtle `bg-primary/5` tint, an `ArrowRight` + icon next to "Recommended Next Step", a stronger separator before + the rationale collapsible, and the rationale itself renders in a + bordered indented block. +- New `/analyzer/tickets` browse page — pill-style period chips + (Today, Yesterday, This week, Last week, Last 30/60 days, All time), + a client (company) Select, an issue-type Select, and a debounced + free-text search across ticket_number/title. Compact table with + per-row Analyze/Re-analyze button (reusing ``) and a + "View" button shortcut to the existing analysis when one is + recorded. Active-filter count + clear-all in the filter card header. +- New API `GET /api/analyzer/tickets/list` — filters by `period` + (computed in Postgres against `last_activity_date`), `companyId`, + `issueType`, `search`. Returns 50 rows + total via `COUNT(*) OVER ()`, + plus `latestAnalysisId` from a LATERAL join into `analyzer_analyses`. +- New API `GET /api/analyzer/tickets/filter-options` — companies that + have at least one non-deleted ticket (drops dormant accounts) + + active issue types ordered by `sort_order, label`. +- Top-level "Analyzer" nav menu added to `app-navigation.tsx`, with + "Browse Tickets" + "Needs Review". Earlier phases left this off + intentionally; this phase opts in. + +**Decisions worth flagging** + +- **Period filters on `last_activity_date`, not `create_date`.** + "Today" surfaces tickets that had activity today (new tickets, + re-opened, status churn) — much more useful for an analyzer-driven + triage flow than tickets created today. A new ticket created today + also has activity today, so we don't lose those. +- **Period math runs in Postgres via `date_trunc('day', NOW())` etc.** + Database server-clock = app-process clock for an internal Docker + stack, so naive timestamps and naive `NOW()` agree. If users + complain about edge-of-day drift, swap to + `NOW() AT TIME ZONE 'America/New_York'` — Pulse's primary user base. +- **Default period is `last_30d`.** "All time" pulls many thousands of + rows; defaulting wide-open hurts first-page latency. 30 days hits + ~7K rows in our DB, paginates cleanly. +- **Per-row analyze button reuses `` directly.** Each + row gets its own component instance — no shared state, the running + state lives per-button. The button navigates on completion, which + feels right: click Analyze, watch the stages, land on the analysis + page. +- **`force=true` is set automatically on tickets that already have an + analysis.** Re-analyze should re-run, not short-circuit to the + cached row. The "View" button covers the cached path. +- **No Linear/JIRA-style multi-select filters.** Single-value Selects + are simpler and match the rest of Pulse. + +**Deliberately left out** + +- **No saved views.** A filter URL is shareable, but there's no + bookmark / saved-view UX. Add when someone asks. +- **No `latest_analysis_status` exposure.** A failed analysis doesn't + show up — the LATERAL join filters by `status='complete'`. So a + ticket whose only analysis failed looks like an un-analyzed ticket. + Acceptable: re-running is the intended action there anyway. +- **Search is `ILIKE '%...%'`.** No tsvector / trigram index. 7K-row + scans are sub-100ms in this DB; if the corpus grows past low six + digits, swap in `pg_trgm`. + +--- + ## Status after each phase | Phase | Tests | tsc | Notes | @@ -426,3 +505,4 @@ This file is updated after each phase ships. | 6 | 128 | clean | frontend (no FE tests) | | 7 | 128 | clean | share email via existing SMTP transport | | 8 | 128 | clean | operator runbook + README link | +| 9 | 128 | clean | browse page + analysis-view formatting + nav entry | From bd3401df1c73a8e72b6b0974b4f1f2af21dac8c2 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 14:00:22 -0400 Subject: [PATCH 3/8] =?UTF-8?q?feat(analyzer):=20Phase=202=20=E2=80=94=20f?= =?UTF-8?q?ull=20stage=20persistence,=20fingerprints,=20aggregate=20report?= =?UTF-8?q?s,=20cost=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight sub-phases per docs/ticket-analyzer-phase2-spec.md: 2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot, aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses. model_traces marked LEGACY (kept for back-compat). 2.2 Every pipeline stage records a row to analyzer_stage_executions, success or failure. Worker persists a status='failed' analyzer_analyses row when the pipeline throws so partial stage records have a parent. Pipeline exposes raw triage/sonnet/opus responses for downstream stages. 2.3 Stage 3 prompt updated with markdown formatting rules + banned filler phrases. Added react-markdown + remark-gfm + @tailwindcss/typography. New component replaces ; coerces stray headers to bold paragraphs. 2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant. scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the legacy model_traces blob. 2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/ queue/status/priority/assignee, sticky filter bar, active-filter chips, bulk selection persisted via localStorage, "Analyze N selected" + "Generate aggregate report" actions. New primitive. Staleness uses last_activity_date > completed_at heuristic per spec C.1. 2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists SQL distributions immediately so UI shows partial state during the Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new /:id]). IT Glue context fetcher capped at 200 doc titles. 2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS override. Every gating decision audited. 2.8 Runbook + build notes updated. 128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent (IF NOT EXISTS). model_traces double-write retained — drop in a future migration once aggregate reports have soaked. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/analyzer/reports/[id]/page.tsx | 489 ++++++ app/analyzer/reports/new/page.tsx | 222 +++ app/analyzer/reports/page.tsx | 152 ++ app/analyzer/tickets/page.tsx | 1062 ++++++++---- .../analyzer/aggregate-reports/[id]/route.ts | 24 + app/api/analyzer/aggregate-reports/route.ts | 210 +++ .../analyzer/tickets/filter-options/route.ts | 92 +- app/api/analyzer/tickets/list/route.ts | 177 -- app/api/analyzer/tickets/route.ts | 397 +++++ app/globals.css | 1 + components/analyzer/analysis-markdown.tsx | 37 + components/analyzer/analysis-view.tsx | 47 +- components/navigation/app-navigation.tsx | 6 + components/ui/multi-select.tsx | 146 ++ docs/ticket-analyzer-phase2-spec.md | 696 ++++++++ .../wulf-pulse-ticket-analyzer-build-notes.md | 288 ++++ docs/wulf-pulse-ticket-analyzer-runbook.md | 169 ++ .../analyzer/aggregate-persistence.ts | 506 ++++++ lib/services/analyzer/cost-guard.ts | 155 ++ lib/services/analyzer/persistence.ts | 116 +- lib/services/analyzer/pipeline.ts | 183 +- .../analyzer/stages/aggregate-reduce.ts | 170 ++ .../analyzer/stages/stage3-deep-analysis.ts | 37 +- .../analyzer/stages/stage6-fingerprint.ts | 135 ++ lib/services/analyzer/worker.test.ts | 21 + lib/services/analyzer/worker.ts | 96 ++ lib/types/analyzer.ts | 200 +++ migrations/070_analyzer_phase2_schema.sql | 76 + migrations/071_analyzer_aggregate_reports.sql | 86 + migrations/072_analyzer_cost_audit.sql | 26 + package-lock.json | 1511 ++++++++++++++++- package.json | 3 + scripts/backfill-fingerprints.ts | 150 ++ 33 files changed, 7132 insertions(+), 554 deletions(-) create mode 100644 app/analyzer/reports/[id]/page.tsx create mode 100644 app/analyzer/reports/new/page.tsx create mode 100644 app/analyzer/reports/page.tsx create mode 100644 app/api/analyzer/aggregate-reports/[id]/route.ts create mode 100644 app/api/analyzer/aggregate-reports/route.ts delete mode 100644 app/api/analyzer/tickets/list/route.ts create mode 100644 app/api/analyzer/tickets/route.ts create mode 100644 components/analyzer/analysis-markdown.tsx create mode 100644 components/ui/multi-select.tsx create mode 100644 docs/ticket-analyzer-phase2-spec.md create mode 100644 lib/services/analyzer/aggregate-persistence.ts create mode 100644 lib/services/analyzer/cost-guard.ts create mode 100644 lib/services/analyzer/stages/aggregate-reduce.ts create mode 100644 lib/services/analyzer/stages/stage6-fingerprint.ts create mode 100644 migrations/070_analyzer_phase2_schema.sql create mode 100644 migrations/071_analyzer_aggregate_reports.sql create mode 100644 migrations/072_analyzer_cost_audit.sql create mode 100644 scripts/backfill-fingerprints.ts diff --git a/app/analyzer/reports/[id]/page.tsx b/app/analyzer/reports/[id]/page.tsx new file mode 100644 index 0000000..e690cbd --- /dev/null +++ b/app/analyzer/reports/[id]/page.tsx @@ -0,0 +1,489 @@ +'use client'; + +import { useEffect, useState, use } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + ArrowLeft, + AlertTriangle, + Loader2, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { AnalysisMarkdown } from '@/components/analyzer/analysis-markdown'; + +type Report = { + id: string; + generatedAt: string; + reportTitle: string | null; + ticketCount: number; + status: 'pending' | 'running' | 'complete' | 'failed'; + errorMessage: string | null; + estimatedCostUsd: number | null; + modelUsed: string | null; + totalInputTokens: number | null; + totalOutputTokens: number | null; + itglueContextIncluded: boolean | null; + dateRangeActual: { earliest: string | null; latest: string | null } | null; + categoryDistribution: Record | null; + clientDistribution: Record | null; + resolutionPathDistribution: Record | null; + rootCauseDistribution: Record | null; + documentationGaps: + | Array<{ + gap: string; + frequency: number; + example_ticket_numbers: string[]; + evidence: string; + itglue_check: 'no_doc_exists' | 'doc_exists_but_unused' | 'unable_to_verify'; + }> + | null; + processGaps: + | Array<{ + gap: string; + frequency: number; + severity: 'low' | 'medium' | 'high'; + example_ticket_numbers: string[]; + evidence: string; + }> + | null; + clientPatterns: + | Array<{ + client: string; + pattern: string; + frequency: number; + example_ticket_numbers: string[]; + }> + | null; + recurrenceClusters: + | Array<{ theme: string; ticket_numbers: string[]; summary: string }> + | null; + systemicObservations: + | Array<{ observation: string; evidence: string; severity: 'low' | 'medium' | 'high' }> + | null; + recommendedActions: + | Array<{ + action: string; + rationale: string; + priority: 'low' | 'medium' | 'high'; + type: 'documentation' | 'process' | 'training' | 'tooling'; + }> + | null; + narrativeSummary: string | null; + executiveSummary: string | null; +}; + +const SEVERITY_TONE: Record = { + high: 'border-red-500 bg-red-500/5', + medium: 'border-amber-500 bg-amber-500/5', + low: 'border-blue-500 bg-blue-500/5', +}; + +const ITGLUE_CHECK_TONE: Record = { + no_doc_exists: 'text-red-600 border-red-300 dark:text-red-400 dark:border-red-800', + doc_exists_but_unused: + 'text-amber-600 border-amber-300 dark:text-amber-400 dark:border-amber-800', + unable_to_verify: 'text-muted-foreground', +}; + +const ITGLUE_CHECK_LABEL: Record = { + no_doc_exists: 'No doc exists', + doc_exists_but_unused: 'Doc exists but unused', + unable_to_verify: 'Unable to verify', +}; + +function MiniBars({ data }: { data: Record | null }) { + if (!data || Object.keys(data).length === 0) return null; + const entries = Object.entries(data).sort((a, b) => b[1] - a[1]); + const max = entries[0]?.[1] ?? 1; + return ( +

+ {entries.map(([key, val]) => ( +
+
+ {key} + {val} +
+
+
+
+
+ ))} +
+ ); +} + +export default function AggregateReportPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + async function load() { + try { + const res = await fetch(`/api/analyzer/aggregate-reports/${id}`); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const { report } = (await res.json()) as { report: Report }; + if (cancelled) return; + setReport(report); + setLoading(false); + if (report.status === 'pending' || report.status === 'running') { + timer = setTimeout(load, 3000); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Unknown error'); + setLoading(false); + } + } + void load(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [id]); + + if (loading && !report) + return
Loading…
; + if (error) + return
{error}
; + if (!report) return null; + + return ( +
+
+ +
+ + {/* Header */} + + +
+
+ + {report.reportTitle ?? `Aggregate report · ${report.ticketCount} tickets`} + +

+ {new Date(report.generatedAt).toLocaleString()} + {report.modelUsed && ` · ${report.modelUsed}`} + {report.estimatedCostUsd !== null && ( + <> · ${report.estimatedCostUsd.toFixed(4)} + )} + {report.itglueContextIncluded !== null && ( + <> + {' · '}IT Glue context: {report.itglueContextIncluded ? 'included' : 'no'} + + )} + {report.dateRangeActual?.earliest && report.dateRangeActual?.latest && ( + <> + {' · '} + {new Date(report.dateRangeActual.earliest).toLocaleDateString()} + {' – '} + {new Date(report.dateRangeActual.latest).toLocaleDateString()} + + )} +

+
+ +
+
+
+ + {report.status === 'failed' && report.errorMessage && ( + + + +
+

Report generation failed

+

+ {report.errorMessage} +

+
+
+
+ )} + + {(report.status === 'pending' || report.status === 'running') && ( + + + +
+ {report.status === 'pending' ? 'Queued.' : 'Running.'} The page will + refresh automatically when the report is ready (typically 30–90s). +
+
+
+ )} + + {/* Executive summary */} + {report.executiveSummary && ( + + + + Executive Summary + + + + + {report.executiveSummary} + + + + )} + + {/* Distributions */} + {(report.categoryDistribution || + report.rootCauseDistribution || + report.resolutionPathDistribution || + report.clientDistribution) && ( +
+ + + Categories + + + + + + + + Root cause + + + + + + + + Resolution + + + + + + + + Clients + + + + + +
+ )} + + {/* Documentation gaps */} + {report.documentationGaps && report.documentationGaps.length > 0 && ( + + + Documentation gaps + + + {report.documentationGaps.map((g, i) => ( +
+
+ + {ITGLUE_CHECK_LABEL[g.itglue_check]} + + {g.frequency}× +

{g.gap}

+
+

{g.evidence}

+
+ {g.example_ticket_numbers.map((tn) => ( + + {tn} + + ))} +
+
+ ))} +
+
+ )} + + {/* Process gaps */} + {report.processGaps && report.processGaps.length > 0 && ( + + + Process gaps + + + {report.processGaps.map((g, i) => ( +
+
+ + {g.severity} + + {g.frequency}× +

{g.gap}

+
+

{g.evidence}

+
+ {g.example_ticket_numbers.map((tn) => ( + + {tn} + + ))} +
+
+ ))} +
+
+ )} + + {/* Recurrence clusters */} + {report.recurrenceClusters && report.recurrenceClusters.length > 0 && ( + + + Recurrence clusters + + + {report.recurrenceClusters.map((c, i) => ( +
+
{c.theme}
+ {c.summary} +
+ {c.ticket_numbers.map((tn) => ( + + {tn} + + ))} +
+
+ ))} +
+
+ )} + + {/* Recommended actions */} + {report.recommendedActions && report.recommendedActions.length > 0 && ( + + + Recommended actions + + + {report.recommendedActions + .slice() + .sort( + (a, b) => + ['low', 'medium', 'high'].indexOf(b.priority) - + ['low', 'medium', 'high'].indexOf(a.priority) + ) + .map((a, i) => ( +
+
+ + {a.priority} + + + {a.type} + +
+ + {a.action} + + + {a.rationale} + +
+ ))} +
+
+ )} + + {/* Narrative summary */} + {report.narrativeSummary && ( + + + Narrative summary + + + + {report.narrativeSummary} + + + + )} +
+ ); +} + +function StatusBadge({ status }: { status: Report['status'] }) { + if (status === 'complete') + return ( + + + Complete + + ); + if (status === 'failed') + return ( + + + Failed + + ); + return ( + + + {status === 'pending' ? 'Queued' : 'Running'} + + ); +} diff --git a/app/analyzer/reports/new/page.tsx b/app/analyzer/reports/new/page.tsx new file mode 100644 index 0000000..5db754e --- /dev/null +++ b/app/analyzer/reports/new/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useEffect, useMemo, useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { ArrowLeft, Sparkles, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; + +function NewReportInner() { + const router = useRouter(); + const params = useSearchParams(); + const ticketNumbers = useMemo( + () => + (params.get('ids') ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + [params] + ); + + const [title, setTitle] = useState(''); + const [includeItglue, setIncludeItglue] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [warning, setWarning] = useState(null); + + useEffect(() => { + if (ticketNumbers.length === 0) + setWarning('No tickets specified. Pick tickets from the browse page first.'); + else if (ticketNumbers.length > 100) + setWarning( + `${ticketNumbers.length} tickets selected — the cap is 100. Narrow the selection or split into multiple reports.` + ); + else setWarning(null); + }, [ticketNumbers]); + + async function postReport(confirmedCost: boolean) { + const res = await fetch('/api/analyzer/aggregate-reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ticketNumbers, + includeItglueContext: includeItglue, + reportTitle: title.trim() || null, + confirmedCost, + }), + }); + return { + ok: res.ok, + status: res.status, + data: (await res.json().catch(() => ({}))) as { + reportId?: string; + error?: string; + message?: string; + missingFingerprint?: string[]; + staleAnalyses?: string[]; + hint?: string; + requiresConfirmation?: boolean; + estimatedCost?: number; + dailySpendBefore?: number; + }, + }; + } + + async function handleGenerate() { + if (ticketNumbers.length === 0 || ticketNumbers.length > 100) return; + setSubmitting(true); + try { + let attempt = await postReport(false); + if ( + !attempt.ok && + attempt.status === 400 && + attempt.data.requiresConfirmation && + attempt.data.estimatedCost + ) { + const ok = window.confirm( + `Estimated cost for this report is $${attempt.data.estimatedCost.toFixed( + 2 + )} (above $5 threshold). Daily spend so far: $${(attempt.data.dailySpendBefore ?? 0).toFixed(2)}.\n\nProceed?` + ); + if (!ok) { + setSubmitting(false); + return; + } + attempt = await postReport(true); + } + if (!attempt.ok || !attempt.data.reportId) { + const d = attempt.data; + const detail = d.missingFingerprint?.length + ? ` Missing fingerprint on: ${d.missingFingerprint.slice(0, 5).join(', ')}${ + d.missingFingerprint.length > 5 + ? `, +${d.missingFingerprint.length - 5} more` + : '' + }.` + : d.staleAnalyses?.length + ? ` Stale: ${d.staleAnalyses.slice(0, 5).join(', ')}${ + d.staleAnalyses.length > 5 + ? `, +${d.staleAnalyses.length - 5} more` + : '' + }.` + : ''; + throw new Error( + (d.message ?? d.error ?? 'Request failed') + + detail + + (d.hint ? ` (${d.hint})` : '') + ); + } + router.push(`/analyzer/reports/${attempt.data.reportId}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Unknown error'); + setSubmitting(false); + } + } + + return ( +
+
+ +
+
+

+ Generate aggregate report +

+

+ Cross-ticket analysis over the {ticketNumbers.length} ticket + {ticketNumbers.length === 1 ? '' : 's'} you selected. +

+
+ + {warning && ( + + + +

{warning}

+
+
+ )} + + + + Selected tickets + + +
+ {ticketNumbers.length === 0 ? ( + None + ) : ( + ticketNumbers.map((tn) => ( + + {tn} + + )) + )} +
+
+
+ + + + Options + + +
+ + setTitle(e.target.value)} + maxLength={200} + /> +
+ +
+
+ +
+ + +
+
+ ); +} + +export default function NewReportPage() { + return ( + Loading…
}> + + + ); +} diff --git a/app/analyzer/reports/page.tsx b/app/analyzer/reports/page.tsx new file mode 100644 index 0000000..0e855aa --- /dev/null +++ b/app/analyzer/reports/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'; + +interface ReportSummary { + id: string; + generatedAt: string; + reportTitle: string | null; + ticketCount: number; + status: 'pending' | 'running' | 'complete' | 'failed'; + estimatedCostUsd: number | null; + modelUsed: string | null; + generatedByUserId: string | null; +} + +export default function ReportsListPage() { + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch('/api/analyzer/aggregate-reports') + .then((r) => (r.ok ? r.json() : Promise.reject(r))) + .then((data: { reports: ReportSummary[] }) => { + if (!cancelled) setReports(data.reports); + }) + .catch((err) => { + if (!cancelled) + setError(err instanceof Error ? err.message : 'Failed to load reports'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+
+

+ Aggregate reports +

+

+ Cross-ticket pattern analysis. Generate a new one from the browse + page. +

+
+ +
+ + + + {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : reports.length === 0 ? ( +
+

No reports yet.

+

+ Pick tickets on the browse page and click{' '} + Generate aggregate report. +

+
+ ) : ( + + + + Status + Title + Tickets + Model + Cost + Generated + Action + + + + {reports.map((r) => ( + + + {r.status === 'complete' ? ( + + + Complete + + ) : r.status === 'failed' ? ( + + + Failed + + ) : ( + + + {r.status} + + )} + + + {r.reportTitle ?? ( + Untitled + )} + + {r.ticketCount} + + {r.modelUsed ?? '—'} + + + {r.estimatedCostUsd === null + ? '—' + : `$${r.estimatedCostUsd.toFixed(4)}`} + + + {new Date(r.generatedAt).toLocaleString()} + + + + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/tickets/page.tsx b/app/analyzer/tickets/page.tsx index 31511f5..6be9fcb 100644 --- a/app/analyzer/tickets/page.tsx +++ b/app/analyzer/tickets/page.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, @@ -22,14 +24,21 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + MultiSelect, + type MultiSelectOption, +} from '@/components/ui/multi-select'; import { Search, Sparkles, CheckCircle2, - Filter, + Filter as FilterIcon, X, + AlertTriangle, + CircleDot, + Clock, } from 'lucide-react'; -import { AnalyzeButton } from '@/components/analyzer/analyze-button'; +import { toast } from 'sonner'; type Period = | 'today' @@ -38,45 +47,56 @@ type Period = | 'last_week' | 'last_30d' | 'last_60d' + | 'custom' | 'all'; -interface PeriodOption { - value: Period; - label: string; -} - -const PERIOD_OPTIONS: PeriodOption[] = [ +const PERIOD_OPTIONS: { value: Period; label: string }[] = [ { value: 'today', label: 'Today' }, { value: 'yesterday', label: 'Yesterday' }, { value: 'this_week', label: 'This week' }, { value: 'last_week', label: 'Last week' }, - { value: 'last_30d', label: 'Last 30 days' }, - { value: 'last_60d', label: 'Last 60 days' }, - { value: 'all', label: 'All time' }, + { value: 'last_30d', label: '30d' }, + { value: 'last_60d', label: '60d' }, + { value: 'custom', label: 'Custom' }, + { value: 'all', label: 'All' }, ]; +type AnalyzedFilter = 'any' | 'yes' | 'no' | 'stale'; + interface TicketRow { ticketNumber: string; + autotaskTicketId: number; title: string | null; - companyName: string | null; - issueTypeLabel: string | null; - statusLabel: string | null; - priorityLabel: string | null; - lastActivityDate: string | null; - createDate: string | null; + clientName: string | null; + clientId: number | null; + status: string | null; + priority: string | null; + queue: string | null; + issueType: string | null; + subIssueType: string | null; + assignedResourceName: string | null; + createdAtAutotask: string | null; + lastActivityAtAutotask: string | null; + ageInDays: number | null; + analyzedState: 'none' | 'current' | 'stale'; latestAnalysisId: string | null; - latestAnalysisVersion: number | null; + latestAnalysisAt: string | null; + needsHumanReview: boolean; + confidenceScore: number | null; + primaryCategory: string | null; } interface FilterOptions { companies: { id: string; name: string }[]; issueTypes: { value: number; label: string }[]; + queues: { value: number; label: string }[]; + statuses: { value: number; label: string }[]; + priorities: { value: number; label: string }[]; + resources: { id: string; name: string }[]; } const PAGE_SIZE = 50; - -const ALL_COMPANIES = '__all_companies__'; -const ALL_ISSUE_TYPES = '__all_issue_types__'; +const SELECTION_LS_KEY = 'analyzer:ticket-selection:v1'; function formatRelative(iso: string | null): string { if (!iso) return '—'; @@ -92,22 +112,74 @@ function formatRelative(iso: string | null): string { return d.toLocaleDateString(); } +function formatAge(days: number | null): string { + if (days === null) return '—'; + if (days < 1) return '<1d'; + if (days < 30) return `${days}d`; + const months = Math.round(days / 30); + return `${months}mo`; +} + +function readSelection(): string[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(SELECTION_LS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((s) => typeof s === 'string') : []; + } catch { + return []; + } +} + +function writeSelection(ids: string[]) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(SELECTION_LS_KEY, JSON.stringify(ids)); + } catch { + /* quota / disabled — ignore */ + } +} + export default function AnalyzerBrowseTicketsPage() { + const router = useRouter(); + const [period, setPeriod] = useState('last_30d'); - const [companyId, setCompanyId] = useState(ALL_COMPANIES); - const [issueType, setIssueType] = useState(ALL_ISSUE_TYPES); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [clientIds, setClientIds] = useState([]); + const [issueTypes, setIssueTypes] = useState([]); + const [queues, setQueues] = useState([]); + const [statuses, setStatuses] = useState([]); + const [priorities, setPriorities] = useState([]); + const [assignedTo, setAssignedTo] = useState([]); + const [analyzed, setAnalyzed] = useState('any'); + const [needsReview, setNeedsReview] = useState(false); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); + const [sort, setSort] = useState< + | 'last_activity_desc' + | 'last_activity_asc' + | 'created_desc' + | 'created_asc' + | 'priority' + >('last_activity_desc'); const [tickets, setTickets] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [filterOptions, setFilterOptions] = useState(null); - // Debounce search input → applied search + // Bulk selection (session-scoped via localStorage) + const [selected, setSelected] = useState(() => readSelection()); + const selectedSet = useMemo(() => new Set(selected), [selected]); + + // Bulk-action busy state (Analyze N tickets sequential) + const [bulkRunning, setBulkRunning] = useState(false); + + // Debounce search input useEffect(() => { const t = setTimeout(() => { setSearch(searchInput.trim()); @@ -116,10 +188,23 @@ export default function AnalyzerBrowseTicketsPage() { return () => clearTimeout(t); }, [searchInput]); - // Reset page when filters change + // Reset to page 0 on any filter change useEffect(() => { setPage(0); - }, [period, companyId, issueType]); + }, [ + period, + startDate, + endDate, + clientIds, + issueTypes, + queues, + statuses, + priorities, + assignedTo, + analyzed, + needsReview, + sort, + ]); // Load filter options once useEffect(() => { @@ -131,26 +216,50 @@ export default function AnalyzerBrowseTicketsPage() { }) .catch(() => { if (!cancelled) - setFilterOptions({ companies: [], issueTypes: [] }); + setFilterOptions({ + companies: [], + issueTypes: [], + queues: [], + statuses: [], + priorities: [], + resources: [], + }); }); return () => { cancelled = true; }; }, []); + // Persist selection on change + useEffect(() => { + writeSelection(selected); + }, [selected]); + const fetchTickets = useCallback(async () => { setLoading(true); setError(null); try { const params = new URLSearchParams({ period, + sort, limit: String(PAGE_SIZE), offset: String(page * PAGE_SIZE), }); - if (companyId !== ALL_COMPANIES) params.set('companyId', companyId); - if (issueType !== ALL_ISSUE_TYPES) params.set('issueType', issueType); + if (period === 'custom' && startDate && endDate) { + params.set('startDate', startDate); + params.set('endDate', endDate); + } + if (clientIds.length) params.set('clientId', clientIds.join(',')); + if (issueTypes.length) params.set('issueType', issueTypes.join(',')); + if (queues.length) params.set('queue', queues.join(',')); + if (statuses.length) params.set('status', statuses.join(',')); + if (priorities.length) params.set('priority', priorities.join(',')); + if (assignedTo.length) params.set('assignedTo', assignedTo.join(',')); + if (analyzed !== 'any') params.set('analyzed', analyzed); + if (needsReview) params.set('needsReview', 'true'); if (search) params.set('search', search); - const res = await fetch(`/api/analyzer/tickets/list?${params.toString()}`); + + const res = await fetch(`/api/analyzer/tickets?${params.toString()}`); if (!res.ok) { const data = (await res.json().catch(() => ({}))) as { error?: string; @@ -158,21 +267,32 @@ export default function AnalyzerBrowseTicketsPage() { }; throw new Error(data.message ?? data.error ?? `Failed: ${res.status}`); } - const data = (await res.json()) as { - tickets: TicketRow[]; - total: number; - }; + const data = (await res.json()) as { tickets: TicketRow[]; total: number }; setTickets(data.tickets); setTotal(data.total); } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - setError(msg); + setError(err instanceof Error ? err.message : 'Unknown error'); setTickets([]); setTotal(0); } finally { setLoading(false); } - }, [period, companyId, issueType, search, page]); + }, [ + period, + startDate, + endDate, + clientIds, + issueTypes, + queues, + statuses, + priorities, + assignedTo, + analyzed, + needsReview, + search, + sort, + page, + ]); useEffect(() => { void fetchTickets(); @@ -180,191 +300,638 @@ export default function AnalyzerBrowseTicketsPage() { const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const activeFilterCount = useMemo(() => { - let n = 0; - if (period !== 'last_30d') n++; - if (companyId !== ALL_COMPANIES) n++; - if (issueType !== ALL_ISSUE_TYPES) n++; - if (search) n++; - return n; - }, [period, companyId, issueType, search]); + const activeFilters = useMemo(() => { + const chips: { key: string; label: string; clear: () => void }[] = []; + if (period !== 'last_30d') + chips.push({ + key: 'period', + label: + period === 'custom' && startDate && endDate + ? `${startDate} → ${endDate}` + : (PERIOD_OPTIONS.find((p) => p.value === period)?.label ?? period), + clear: () => { + setPeriod('last_30d'); + setStartDate(''); + setEndDate(''); + }, + }); + const lookup = ( + arr: { value: number | string; label: string }[] | undefined, + sel: string[] + ) => + sel + .map( + (s) => + arr?.find((o) => String(o.value ?? (o as { id?: string }).id ?? '') === s) + ?.label ?? s + ) + .join(', '); + if (clientIds.length) + chips.push({ + key: 'client', + label: `Client: ${clientIds.length}`, + clear: () => setClientIds([]), + }); + if (issueTypes.length) + chips.push({ + key: 'issue', + label: `Issue type: ${lookup(filterOptions?.issueTypes, issueTypes)}`, + clear: () => setIssueTypes([]), + }); + if (queues.length) + chips.push({ + key: 'queue', + label: `Queue: ${lookup(filterOptions?.queues, queues)}`, + clear: () => setQueues([]), + }); + if (statuses.length) + chips.push({ + key: 'status', + label: `Status: ${lookup(filterOptions?.statuses, statuses)}`, + clear: () => setStatuses([]), + }); + if (priorities.length) + chips.push({ + key: 'priority', + label: `Priority: ${lookup(filterOptions?.priorities, priorities)}`, + clear: () => setPriorities([]), + }); + if (assignedTo.length) + chips.push({ + key: 'assigned', + label: `Assigned: ${assignedTo.length}`, + clear: () => setAssignedTo([]), + }); + if (analyzed !== 'any') + chips.push({ + key: 'analyzed', + label: `Analyzed: ${analyzed}`, + clear: () => setAnalyzed('any'), + }); + if (needsReview) + chips.push({ + key: 'needsReview', + label: 'Needs review', + clear: () => setNeedsReview(false), + }); + if (search) + chips.push({ + key: 'search', + label: `"${search}"`, + clear: () => { + setSearchInput(''); + setSearch(''); + }, + }); + return chips; + }, [ + period, + startDate, + endDate, + clientIds, + issueTypes, + queues, + statuses, + priorities, + assignedTo, + analyzed, + needsReview, + search, + filterOptions, + ]); - function clearFilters() { + function clearAll() { setPeriod('last_30d'); - setCompanyId(ALL_COMPANIES); - setIssueType(ALL_ISSUE_TYPES); + setStartDate(''); + setEndDate(''); + setClientIds([]); + setIssueTypes([]); + setQueues([]); + setStatuses([]); + setPriorities([]); + setAssignedTo([]); + setAnalyzed('any'); + setNeedsReview(false); setSearchInput(''); setSearch(''); } - const companyName = useMemo(() => { - if (companyId === ALL_COMPANIES) return null; - return ( - filterOptions?.companies.find((c) => c.id === companyId)?.name ?? null + const visibleSelectableCount = tickets.length; + const visibleSelectedCount = tickets.filter((t) => + selectedSet.has(t.ticketNumber) + ).length; + const allOnPageSelected = + visibleSelectableCount > 0 && + visibleSelectedCount === visibleSelectableCount; + const someOnPageSelected = + visibleSelectedCount > 0 && !allOnPageSelected; + + function toggleSelectAllOnPage() { + if (allOnPageSelected) { + setSelected((prev) => + prev.filter((id) => !tickets.some((t) => t.ticketNumber === id)) + ); + } else { + const merged = new Set(selected); + tickets.forEach((t) => merged.add(t.ticketNumber)); + setSelected(Array.from(merged)); + } + } + + function toggleRow(ticketNumber: string) { + setSelected((prev) => + prev.includes(ticketNumber) + ? prev.filter((id) => id !== ticketNumber) + : [...prev, ticketNumber] ); - }, [companyId, filterOptions]); + } + + function clearSelection() { + setSelected([]); + } + + // Lookup of currently-selected tickets in the loaded set, for action gating. + const selectedRows = useMemo( + () => tickets.filter((t) => selectedSet.has(t.ticketNumber)), + [tickets, selectedSet] + ); + const selectedAllAnalyzedAndCurrent = useMemo(() => { + if (selected.length === 0) return false; + if (selectedRows.length !== selected.length) return false; // some selections off-page + return selectedRows.every((r) => r.analyzedState === 'current'); + }, [selectedRows, selected]); + const selectedAnyNeedsAnalyze = useMemo(() => { + if (selectedRows.length === 0) return false; + return selectedRows.some( + (r) => r.analyzedState === 'none' || r.analyzedState === 'stale' + ); + }, [selectedRows]); + + async function bulkAnalyze() { + if (selectedRows.length === 0) return; + const targets = selectedRows.filter( + (r) => r.analyzedState !== 'current' + ); + if (targets.length === 0) { + toast.info('All selected tickets already have a current analysis.'); + return; + } + if ( + !confirm( + `Analyze ${targets.length} ticket${targets.length === 1 ? '' : 's'}? This will queue ${targets.length} job${targets.length === 1 ? '' : 's'}.` + ) + ) + return; + setBulkRunning(true); + let queued = 0; + let failed = 0; + for (const t of targets) { + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(t.ticketNumber)}/analyze`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: t.analyzedState === 'stale' }), + } + ); + if (res.ok) queued++; + else failed++; + } catch { + failed++; + } + } + setBulkRunning(false); + toast.success( + `Queued ${queued} job${queued === 1 ? '' : 's'}${failed ? ` (${failed} failed)` : ''}. Worker will pick them up shortly.` + ); + void fetchTickets(); + } + + function bulkAggregateReport() { + if (!selectedAllAnalyzedAndCurrent) { + toast.error( + 'All selected tickets must be analyzed and current before generating an aggregate report.' + ); + return; + } + const params = new URLSearchParams({ + ids: selected.join(','), + }); + router.push(`/analyzer/reports/new?${params.toString()}`); + } return ( -
+

Browse Tickets to Analyze

- Filter by activity window, client, or issue type — click Analyze to - run the AI pipeline against any ticket. + Filter, select, and analyze in bulk. Selections persist across + pagination via localStorage.

- +
+ + +
- {/* Filter bar */} - - -
- - - Filters - {activeFilterCount > 0 && ( - - {activeFilterCount} - - )} - - {activeFilterCount > 0 && ( - + {p.label} + + ))} + {period === 'custom' && ( +
+ setStartDate(e.target.value)} + className="h-8 w-[140px]" + /> + + setEndDate(e.target.value)} + className="h-8 w-[140px]" + /> +
)}
-
- - {/* Period chips */} -
- -
- {PERIOD_OPTIONS.map((p) => ( + + {/* Multi-selects + analyzed + search */} +
+ ({ + value: c.id, + label: c.name, + })) as MultiSelectOption[] + } + value={clientIds} + onChange={setClientIds} + placeholder="Client" + searchPlaceholder="Search clients…" + /> + ({ + value: String(it.value), + label: it.label, + })) as MultiSelectOption[] + } + value={issueTypes} + onChange={setIssueTypes} + placeholder="Issue type" + /> + ({ + value: String(q.value), + label: q.label, + })) as MultiSelectOption[] + } + value={queues} + onChange={setQueues} + placeholder="Queue" + /> + ({ + value: String(s.value), + label: s.label, + })) as MultiSelectOption[] + } + value={statuses} + onChange={setStatuses} + placeholder="Status" + /> + ({ + value: String(p.value), + label: p.label, + })) as MultiSelectOption[] + } + value={priorities} + onChange={setPriorities} + placeholder="Priority" + /> + ({ + value: r.id, + label: r.name, + })) as MultiSelectOption[] + } + value={assignedTo} + onChange={setAssignedTo} + placeholder="Assigned to" + searchPlaceholder="Search resources…" + /> +
+ + setSearchInput(e.target.value)} + /> +
+
+ + {/* Analyzed segmented + needs review + sort + clear */} +
+
+ {(['any', 'yes', 'no', 'stale'] as const).map((opt) => ( ))}
-
- - {/* Other filters in a grid */} -
-
- - setSort(v as typeof sort)}> + + - All clients - {(filterOptions?.companies ?? []).map((c) => ( - - {c.name} - - ))} + Last activity ↓ + Last activity ↑ + Newest first + Oldest first + Priority -
- -
- - -
- -
- -
- - setSearchInput(e.target.value)} - /> -
-
-
- - - - {/* Results */} - - -
- - {loading - ? 'Loading…' - : total === 0 - ? 'No tickets match' - : total === 1 - ? '1 ticket' - : `${total.toLocaleString()} tickets`} - {companyName && total > 0 && ( - - · {companyName} - + {activeFilters.length > 0 && ( + )} - - {total > PAGE_SIZE && ( -
- - Page {page + 1} of {totalPages} - +
+
+ + {/* Active filter chips */} + {activeFilters.length > 0 && ( +
+ {activeFilters.map((f) => ( + + {f.label} + + + ))} +
+ )} +
+
+ + {/* Bulk actions + count */} +
+
+ + {loading + ? 'Loading…' + : `${total.toLocaleString()} ticket${total === 1 ? '' : 's'}`} + {selected.length > 0 && ( + {selected.length} selected + )} + {selected.length > 0 && ( + + )} +
+
+ + +
+
+ + {/* Results table */} + + + {error ? ( +
{error}
+ ) : tickets.length === 0 && !loading ? ( +
+ +

No tickets match the current filters.

+

+ Try widening the period or clearing some filters. +

+
+ ) : ( + + + + + + + St. + Ticket + Title + Client + Status + Priority + Age + Last activity + Assigned + Action + + + + {tickets.map((t) => ( + + + toggleRow(t.ticketNumber)} + aria-label={`Select ${t.ticketNumber}`} + /> + + + + + + + {t.ticketNumber} + + + +
+ + {t.title ?? ( + + No title + + )} + + {t.needsHumanReview && ( + + + Review + + )} +
+
+ + {t.clientName ?? } + + + {t.status ? ( + + {t.status} + + ) : ( + + )} + + + {t.priority ?? } + + + {formatAge(t.ageInDays)} + + + {formatRelative(t.lastActivityAtAutotask)} + + + {t.assignedResourceName ?? ( + + )} + + + {t.latestAnalysisId ? ( + + ) : ( + + )} + +
+ ))} +
+
+ )} + + {total > PAGE_SIZE && ( +
+ + Page {page + 1} of {totalPages} + +
- )} -
- - - {error ? ( -
{error}
- ) : tickets.length === 0 && !loading ? ( -
- -

No tickets match the current filters.

-

- Try widening the period or clearing some filters. -

- ) : ( - - - - Ticket - Title - Client - Issue type - Status - Last activity - - Actions - - - - - {tickets.map((t) => ( - - - - {t.ticketNumber} - - - -
- - {t.title ?? No title} - - {t.latestAnalysisId && ( - - - v{t.latestAnalysisVersion} - - )} -
-
- - {t.companyName ?? } - - - {t.issueTypeLabel ?? } - - - {t.statusLabel ? ( - {t.statusLabel} - ) : ( - - )} - - - {formatRelative(t.lastActivityDate)} - - -
- {t.latestAnalysisId && ( - - )} - -
-
-
- ))} -
-
)}
); } + +function AnalyzedDot({ state }: { state: 'none' | 'current' | 'stale' }) { + if (state === 'current') { + return ( + + ); + } + if (state === 'stale') { + return ( + + ); + } + return ( + + ); +} diff --git a/app/api/analyzer/aggregate-reports/[id]/route.ts b/app/api/analyzer/aggregate-reports/[id]/route.ts new file mode 100644 index 0000000..69e2859 --- /dev/null +++ b/app/api/analyzer/aggregate-reports/[id]/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/analyzer/aggregate-reports/:id + * + * Returns the full report row. Frontend polls this for completion when the + * report is in 'pending'/'running' status. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { getAggregateReport } from '@/lib/services/analyzer/aggregate-persistence'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const report = await getAggregateReport(id); + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + return NextResponse.json({ report }); +} diff --git a/app/api/analyzer/aggregate-reports/route.ts b/app/api/analyzer/aggregate-reports/route.ts new file mode 100644 index 0000000..6fd206c --- /dev/null +++ b/app/api/analyzer/aggregate-reports/route.ts @@ -0,0 +1,210 @@ +/** + * POST /api/analyzer/aggregate-reports + * GET /api/analyzer/aggregate-reports + * + * POST: queue a new aggregate report. Validates inputs, creates a 'pending' + * row, fires runAggregateReport in the background, returns immediately with + * the report id. + * + * GET: list reports (paginated, optionally filtered by user). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + createAggregateReport, + listAggregateReports, + runAggregateReport, +} from '@/lib/services/analyzer/aggregate-persistence'; +import { + estimateAggregateReportCost, + evaluateCost, + recordCostAuditDecision, +} from '@/lib/services/analyzer/cost-guard'; + +const MAX_TICKETS_PER_REPORT = 100; + +const PostBody = z.object({ + analysisIds: z.array(z.string().uuid()).max(MAX_TICKETS_PER_REPORT).optional(), + ticketNumbers: z.array(z.string()).max(MAX_TICKETS_PER_REPORT).optional(), + includeItglueContext: z.boolean().default(true), + reportTitle: z.string().max(200).nullable().optional(), + /** Acknowledges the per-request cost guard ($5 threshold). */ + confirmedCost: z.boolean().default(false), +}); + +interface FingerprintCheckRow { + id: string; + ticket_number: string; + analysis_version: number; + has_fingerprint: boolean; + is_stale: boolean; +} + +export async function POST(request: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + const body = await request.json().catch(() => ({})); + const parsed = PostBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const { analysisIds, ticketNumbers, includeItglueContext, reportTitle } = parsed.data; + + // Resolve analysisIds: explicit list > ticketNumbers (latest analysis per ticket). + let resolvedIds: string[] = []; + if (analysisIds && analysisIds.length > 0) { + resolvedIds = analysisIds; + } else if (ticketNumbers && ticketNumbers.length > 0) { + const res = await postgresClient.query<{ id: string }>( + `SELECT DISTINCT ON (ticket_number) id::text AS id + FROM analyzer_analyses + WHERE ticket_number = ANY($1::text[]) + AND status = 'complete' + ORDER BY ticket_number, analysis_version DESC`, + [ticketNumbers] + ); + resolvedIds = res.rows.map((r) => r.id); + } else { + return NextResponse.json( + { error: 'Provide analysisIds or ticketNumbers' }, + { status: 400 } + ); + } + + if (resolvedIds.length === 0) { + return NextResponse.json( + { error: 'No matching complete analyses found for the given inputs' }, + { status: 400 } + ); + } + if (resolvedIds.length > MAX_TICKETS_PER_REPORT) { + return NextResponse.json( + { + error: `Too many tickets (${resolvedIds.length}). Cap is ${MAX_TICKETS_PER_REPORT}.`, + }, + { status: 400 } + ); + } + + // Validate fingerprints + staleness. + const validation = await postgresClient.query( + `SELECT aa.id::text AS id, + aa.ticket_number, + aa.analysis_version, + (aa.aggregate_fingerprint IS NOT NULL) AS has_fingerprint, + (t.last_activity_date > aa.completed_at) AS is_stale + FROM analyzer_analyses aa + LEFT JOIN tickets t ON t.ticket_number = aa.ticket_number + AND t.is_deleted = false + WHERE aa.id = ANY($1::uuid[])`, + [resolvedIds] + ); + const missingFingerprint = validation.rows + .filter((r) => !r.has_fingerprint) + .map((r) => `${r.ticket_number} v${r.analysis_version}`); + const staleAnalyses = validation.rows + .filter((r) => r.is_stale) + .map((r) => `${r.ticket_number} v${r.analysis_version}`); + + if (missingFingerprint.length > 0) { + return NextResponse.json( + { + error: 'Some analyses are missing aggregate_fingerprint', + missingFingerprint, + hint: 'Run scripts/backfill-fingerprints.ts to fill in legacy analyses, or re-analyze.', + }, + { status: 400 } + ); + } + if (staleAnalyses.length > 0) { + return NextResponse.json( + { + error: + 'Some selected tickets have new activity since their last analysis. Re-analyze first.', + staleAnalyses, + }, + { status: 400 } + ); + } + + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + + // Cost-guard evaluation. Hard-blocks at $50/day, asks for confirmation at >$5. + const estimatedCost = estimateAggregateReportCost({ + ticketCount: resolvedIds.length, + includeItglueContext: parsed.data.includeItglueContext, + }); + const evaluation = await evaluateCost({ + userId, + estimatedCost, + confirmedCost: parsed.data.confirmedCost, + }); + await recordCostAuditDecision({ + userId, + action: 'aggregate_report', + evaluation, + context: { ticketCount: resolvedIds.length, includeItglueContext: parsed.data.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 } + ); + } + + const created = await createAggregateReport({ + generatedByUserId: userId, + filterCriteria: { analysisIds: resolvedIds }, + analysisIds: resolvedIds, + ticketCount: resolvedIds.length, + includeItglueContext, + reportTitle: reportTitle ?? null, + }); + + // Fire and forget — runner persists results when done. + void runAggregateReport(created.id).catch((err) => { + console.error('[ANALYZER-REPORT] background runner threw:', err); + }); + + return NextResponse.json({ + reportId: created.id, + status: 'pending', + ticketCount: resolvedIds.length, + }); +} + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit') ?? 50); + const offset = Number(url.searchParams.get('offset') ?? 0); + const reports = await listAggregateReports({ limit, offset }); + return NextResponse.json({ reports }); +} diff --git a/app/api/analyzer/tickets/filter-options/route.ts b/app/api/analyzer/tickets/filter-options/route.ts index e12c641..0be4b9b 100644 --- a/app/api/analyzer/tickets/filter-options/route.ts +++ b/app/api/analyzer/tickets/filter-options/route.ts @@ -4,52 +4,78 @@ * Returns the dropdown source data for the analyzer ticket browser. * Companies are limited to those that have at least one non-deleted ticket * (255 → typically ~150 with tickets) so the dropdown isn't padded with - * dormant accounts. + * dormant accounts. Resources are limited to active ones with at least one + * assigned ticket. */ import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; -interface CompanyRow { - id: string; - company_name: string; -} -interface IssueTypeRow { - value: number; - label: string; -} +interface CompanyRow { id: string; company_name: string } +interface IssueTypeRow { value: number; label: string } +interface QueueRow { value: number; label: string } +interface StatusRow { value: number; label: string } +interface PriorityRow { value: number; label: string } +interface ResourceRow { id: string; full_name: string } export async function GET() { const { error } = await requireAuth(); if (error) return error; - const [companies, issueTypes] = await Promise.all([ - postgresClient.query( - `SELECT c.id::text AS id, c.company_name - FROM companies c - WHERE c.is_active = true - AND c.is_deleted = false - AND EXISTS ( - SELECT 1 FROM tickets t - WHERE t.company_id = c.id AND t.is_deleted = false - ) - ORDER BY c.company_name` - ), - postgresClient.query( - `SELECT value, label - FROM issue_types - WHERE is_active = true - AND is_deleted = false - ORDER BY sort_order NULLS LAST, label` - ), - ]); + const [companies, issueTypes, queues, statuses, priorities, resources] = + await Promise.all([ + postgresClient.query( + `SELECT c.id::text AS id, c.company_name + FROM companies c + WHERE c.is_active = true + AND c.is_deleted = false + AND EXISTS ( + SELECT 1 FROM tickets t + WHERE t.company_id = c.id AND t.is_deleted = false + ) + ORDER BY c.company_name` + ), + postgresClient.query( + `SELECT value, label FROM issue_types + WHERE is_active = true AND is_deleted = false + ORDER BY sort_order NULLS LAST, label` + ), + postgresClient.query( + `SELECT value, label FROM queues + WHERE is_active = true AND is_deleted = false + ORDER BY sort_order NULLS LAST, label` + ), + postgresClient.query( + `SELECT value, label FROM statuses + WHERE is_active = true AND is_deleted = false + ORDER BY sort_order NULLS LAST, label` + ), + postgresClient.query( + `SELECT value, label FROM priorities + WHERE is_active = true AND is_deleted = false + ORDER BY value` + ), + postgresClient.query( + `SELECT r.id::text AS id, + trim(coalesce(r.first_name,'') || ' ' || coalesce(r.last_name,'')) AS full_name + FROM resources r + WHERE r.is_active = true + AND r.is_deleted = false + AND EXISTS ( + SELECT 1 FROM tickets t + WHERE t.assigned_resource_id = r.id AND t.is_deleted = false + ) + ORDER BY 2` + ), + ]); return NextResponse.json({ - companies: companies.rows.map((r) => ({ - id: r.id, - name: r.company_name, - })), + companies: companies.rows.map((r) => ({ id: r.id, name: r.company_name })), issueTypes: issueTypes.rows, + queues: queues.rows, + statuses: statuses.rows, + priorities: priorities.rows, + resources: resources.rows.map((r) => ({ id: r.id, name: r.full_name })), }); } diff --git a/app/api/analyzer/tickets/list/route.ts b/app/api/analyzer/tickets/list/route.ts deleted file mode 100644 index 985d31a..0000000 --- a/app/api/analyzer/tickets/list/route.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * GET /api/analyzer/tickets/list - * - * Browse view backing the /analyzer/tickets page. Filters tickets by - * `last_activity_date` (the most useful axis for "what's worth analyzing - * right now") plus optional company / issue type / free-text search. - * - * Query params: - * period one of today | yesterday | this_week | last_week | last_30d | last_60d | all - * companyId numeric companies.id, optional - * issueType numeric issue_types.value, optional - * search substring match against ticket_number or title - * limit default 50, capped at 200 - * offset default 0 - * - * Returns: - * { tickets: TicketRow[], total: number } - * - * Each row carries `latestAnalysisId` if the ticket already has a complete - * analysis, so the UI can offer "View analysis" alongside "Analyze". - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { requireAuth } from '@/lib/auth-utils'; -import postgresClient from '@/lib/services/postgres-client'; - -type Period = - | 'today' - | 'yesterday' - | 'this_week' - | 'last_week' - | 'last_30d' - | 'last_60d' - | 'all'; - -const ALLOWED_PERIODS: ReadonlySet = new Set([ - 'today', - 'yesterday', - 'this_week', - 'last_week', - 'last_30d', - 'last_60d', - 'all', -]); - -/** - * Returns the SQL fragment for the date predicate. Uses Postgres-side NOW() - * so "today" reflects the database server's clock — this is an internal tool - * and the DB and app process share the same clock. - * - * Returns the predicate string with no parameters — these date expressions - * are constants from the API perspective, computed in Postgres. - */ -function periodPredicate(period: Period): string { - switch (period) { - case 'today': - return `t.last_activity_date >= date_trunc('day', NOW())`; - case 'yesterday': - return `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' - AND t.last_activity_date < date_trunc('day', NOW())`; - case 'this_week': - return `t.last_activity_date >= date_trunc('week', NOW())`; - case 'last_week': - return `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' - AND t.last_activity_date < date_trunc('week', NOW())`; - case 'last_30d': - return `t.last_activity_date >= NOW() - INTERVAL '30 days'`; - case 'last_60d': - return `t.last_activity_date >= NOW() - INTERVAL '60 days'`; - case 'all': - return `TRUE`; - } -} - -interface TicketRow { - ticket_number: string; - title: string | null; - company_name: string | null; - issue_type_label: string | null; - status_label: string | null; - priority_label: string | null; - last_activity_date: Date | null; - create_date: Date | null; - latest_analysis_id: string | null; - latest_analysis_version: number | null; - total_count: string; -} - -export async function GET(request: NextRequest) { - const { error } = await requireAuth(); - if (error) return error; - - const url = new URL(request.url); - const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period; - const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d'; - const companyIdRaw = url.searchParams.get('companyId'); - const companyId = companyIdRaw ? Number(companyIdRaw) : null; - const issueTypeRaw = url.searchParams.get('issueType'); - const issueType = issueTypeRaw ? Number(issueTypeRaw) : null; - const search = (url.searchParams.get('search') ?? '').trim() || null; - const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200); - const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0); - - const sql = ` - WITH filtered AS ( - SELECT t.id, t.ticket_number, t.title, - t.company_id, t.issue_type, t.status, t.priority, - t.last_activity_date, t.create_date - FROM tickets t - WHERE t.is_deleted = false - AND ${periodPredicate(period)} - AND ($1::bigint IS NULL OR t.company_id = $1::bigint) - AND ($2::int IS NULL OR t.issue_type = $2::int) - AND ($3::text IS NULL OR ( - t.ticket_number ILIKE '%' || $3::text || '%' - OR t.title ILIKE '%' || $3::text || '%' - )) - ) - SELECT f.ticket_number, - f.title, - c.company_name, - it.label AS issue_type_label, - s.label AS status_label, - pr.label AS priority_label, - f.last_activity_date, - f.create_date, - latest.id::text AS latest_analysis_id, - latest.analysis_version AS latest_analysis_version, - COUNT(*) OVER () AS total_count - FROM filtered f - LEFT JOIN companies c ON c.id = f.company_id - LEFT JOIN issue_types it ON it.value = f.issue_type - LEFT JOIN statuses s ON s.value = f.status - LEFT JOIN priorities pr ON pr.value = f.priority - LEFT JOIN LATERAL ( - SELECT aa.id, aa.analysis_version - FROM analyzer_analyses aa - WHERE aa.ticket_number = f.ticket_number - AND aa.status = 'complete' - ORDER BY aa.analysis_version DESC - LIMIT 1 - ) latest ON TRUE - ORDER BY f.last_activity_date DESC NULLS LAST - LIMIT $4 OFFSET $5 - `; - - const res = await postgresClient.query(sql, [ - companyId, - issueType, - search, - limit, - offset, - ]); - - const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; - - return NextResponse.json({ - period, - total, - limit, - offset, - tickets: res.rows.map((r) => ({ - ticketNumber: r.ticket_number, - title: r.title, - companyName: r.company_name, - issueTypeLabel: r.issue_type_label, - statusLabel: r.status_label, - priorityLabel: r.priority_label, - lastActivityDate: r.last_activity_date - ? r.last_activity_date.toISOString() - : null, - createDate: r.create_date ? r.create_date.toISOString() : null, - latestAnalysisId: r.latest_analysis_id, - latestAnalysisVersion: r.latest_analysis_version, - })), - }); -} diff --git a/app/api/analyzer/tickets/route.ts b/app/api/analyzer/tickets/route.ts new file mode 100644 index 0000000..1c55837 --- /dev/null +++ b/app/api/analyzer/tickets/route.ts @@ -0,0 +1,397 @@ +/** + * GET /api/analyzer/tickets + * + * Browse view backing /analyzer/tickets. Filters tickets by activity window + * + multi-axis filter set. Joins to analyzer_analyses to surface analyzed + * state per ticket. + * + * Phase 2 staleness heuristic: a ticket is "stale" when + * tickets.last_activity_date > latest_analysis.completed_at + * The spec calls for content-hash-based staleness; that requires either + * caching the current hash on the tickets row or computing on read for the + * visible page. For Phase 2 V1 we use the date heuristic — see + * docs/ticket-analyzer-phase2-spec.md C.1 ("compute on-read for now and + * discuss caching strategy after we see real load") and the build notes. + * + * Query params: + * period today|yesterday|this_week|last_week|last_30d|last_60d|custom|all + * startDate ISO date, only when period=custom + * endDate ISO date, only when period=custom (inclusive) + * clientId comma-separated companies.id values + * issueType comma-separated issue_types.value values + * queue comma-separated queues.value values + * status comma-separated statuses.value values + * priority comma-separated priorities.value values + * assignedTo comma-separated resources.id values + * analyzed any|yes|no|stale (default: any) + * needsReview true|false (default: any) + * sort created_desc|created_asc|last_activity_desc|last_activity_asc|priority + * search substring match against ticket_number or title + * limit default 50, max 200 + * offset default 0 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +type Period = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'last_30d' + | 'last_60d' + | 'custom' + | 'all'; + +const ALLOWED_PERIODS: ReadonlySet = new Set([ + 'today', + 'yesterday', + 'this_week', + 'last_week', + 'last_30d', + 'last_60d', + 'custom', + 'all', +]); + +type AnalyzedFilter = 'any' | 'yes' | 'no' | 'stale'; +const ALLOWED_ANALYZED: ReadonlySet = new Set([ + 'any', + 'yes', + 'no', + 'stale', +]); + +type SortKey = + | 'created_desc' + | 'created_asc' + | 'last_activity_desc' + | 'last_activity_asc' + | 'priority'; +const ALLOWED_SORT: ReadonlySet = new Set([ + 'created_desc', + 'created_asc', + 'last_activity_desc', + 'last_activity_asc', + 'priority', +]); + +function parseCsv(raw: string | null): number[] | null { + if (!raw) return null; + const parts = raw + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + return parts.length > 0 ? parts : null; +} + +/** SQL fragment for the date predicate (no params — date math is in Postgres). */ +function periodPredicate( + period: Period, + startDate: string | null, + endDate: string | null +): { sql: string; params: unknown[] } { + switch (period) { + case 'today': + return { sql: `t.last_activity_date >= date_trunc('day', NOW())`, params: [] }; + case 'yesterday': + return { + sql: `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' + AND t.last_activity_date < date_trunc('day', NOW())`, + params: [], + }; + case 'this_week': + return { sql: `t.last_activity_date >= date_trunc('week', NOW())`, params: [] }; + case 'last_week': + return { + sql: `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' + AND t.last_activity_date < date_trunc('week', NOW())`, + params: [], + }; + case 'last_30d': + return { sql: `t.last_activity_date >= NOW() - INTERVAL '30 days'`, params: [] }; + case 'last_60d': + return { sql: `t.last_activity_date >= NOW() - INTERVAL '60 days'`, params: [] }; + case 'custom': + // Both dates required; if missing fall through to "all". + if (!startDate || !endDate) return { sql: `TRUE`, params: [] }; + return { + sql: `t.last_activity_date >= $__START__ AND t.last_activity_date < ($__END__::timestamptz + INTERVAL '1 day')`, + params: [startDate, endDate], + }; + case 'all': + return { sql: `TRUE`, params: [] }; + } +} + +function sortClause(sort: SortKey): string { + switch (sort) { + case 'created_desc': + return `f.create_date DESC NULLS LAST`; + case 'created_asc': + return `f.create_date ASC NULLS LAST`; + case 'last_activity_desc': + return `f.last_activity_date DESC NULLS LAST`; + case 'last_activity_asc': + return `f.last_activity_date ASC NULLS LAST`; + case 'priority': + // Lower priority value = higher importance in Autotask. + return `f.priority ASC NULLS LAST, f.last_activity_date DESC NULLS LAST`; + } +} + +interface TicketRow { + ticket_number: string; + autotask_ticket_id: string; + title: string | null; + client_name: string | null; + client_id: string | null; + status_label: string | null; + priority_label: string | null; + queue_label: string | null; + issue_type_label: string | null; + sub_issue_type_label: string | null; + assigned_resource_name: string | null; + create_date: Date | null; + last_activity_date: Date | null; + age_in_days: number | null; + latest_analysis_id: string | null; + latest_analysis_at: Date | null; + latest_completed_at: Date | null; + needs_human_review: boolean | null; + confidence_score: string | null; + primary_category: string | null; + total_count: string; +} + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const url = new URL(request.url); + const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period; + const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d'; + const startDate = url.searchParams.get('startDate'); + const endDate = url.searchParams.get('endDate'); + + const clientIds = parseCsv(url.searchParams.get('clientId')); + const issueTypes = parseCsv(url.searchParams.get('issueType')); + const queues = parseCsv(url.searchParams.get('queue')); + const statuses = parseCsv(url.searchParams.get('status')); + const priorities = parseCsv(url.searchParams.get('priority')); + const assignedTo = parseCsv(url.searchParams.get('assignedTo')); + + const analyzedRaw = (url.searchParams.get('analyzed') ?? 'any') as AnalyzedFilter; + const analyzed: AnalyzedFilter = ALLOWED_ANALYZED.has(analyzedRaw) ? analyzedRaw : 'any'; + const needsReviewRaw = url.searchParams.get('needsReview'); + const needsReview = + needsReviewRaw === 'true' ? true : needsReviewRaw === 'false' ? false : null; + + const sortRaw = (url.searchParams.get('sort') ?? 'last_activity_desc') as SortKey; + const sort: SortKey = ALLOWED_SORT.has(sortRaw) ? sortRaw : 'last_activity_desc'; + + const search = (url.searchParams.get('search') ?? '').trim() || null; + const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200); + const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0); + + // Build the parameter list and SQL fragment incrementally so each filter is + // optional. Using $N indexed params; period predicate is an SQL fragment. + const params: unknown[] = []; + const where: string[] = ['t.is_deleted = false']; + + const periodFragment = periodPredicate(period, startDate, endDate); + if (periodFragment.params.length > 0) { + params.push(...periodFragment.params); + let frag = periodFragment.sql; + frag = frag.replace('$__START__', `$${params.length - 1}::timestamptz`); + frag = frag.replace('$__END__', `$${params.length}::timestamptz`); + where.push(frag); + } else { + where.push(periodFragment.sql); + } + + if (clientIds) { + params.push(clientIds); + where.push(`t.company_id = ANY($${params.length}::bigint[])`); + } + if (issueTypes) { + params.push(issueTypes); + where.push(`t.issue_type = ANY($${params.length}::int[])`); + } + if (queues) { + params.push(queues); + where.push(`t.queue_id = ANY($${params.length}::int[])`); + } + if (statuses) { + params.push(statuses); + where.push(`t.status = ANY($${params.length}::int[])`); + } + if (priorities) { + params.push(priorities); + where.push(`t.priority = ANY($${params.length}::int[])`); + } + if (assignedTo) { + params.push(assignedTo); + where.push(`t.assigned_resource_id = ANY($${params.length}::bigint[])`); + } + if (search) { + params.push(search); + where.push( + `(t.ticket_number ILIKE '%' || $${params.length} || '%' OR t.title ILIKE '%' || $${params.length} || '%')` + ); + } + + const analyzedHavingParts: string[] = []; + if (analyzed === 'yes') { + analyzedHavingParts.push(`latest.id IS NOT NULL`); + } else if (analyzed === 'no') { + analyzedHavingParts.push(`latest.id IS NULL`); + } else if (analyzed === 'stale') { + analyzedHavingParts.push( + `latest.id IS NOT NULL AND f.last_activity_date > latest.completed_at` + ); + } + if (needsReview === true) { + analyzedHavingParts.push(`latest.needs_human_review = true`); + } else if (needsReview === false) { + analyzedHavingParts.push( + `(latest.needs_human_review IS DISTINCT FROM true)` + ); + } + const havingFragment = + analyzedHavingParts.length > 0 + ? `WHERE ${analyzedHavingParts.join(' AND ')}` + : ''; + + params.push(limit); + const limitParamIdx = params.length; + params.push(offset); + const offsetParamIdx = params.length; + + const sql = ` + WITH filtered AS ( + SELECT t.id, t.ticket_number, t.title, + t.company_id, t.issue_type, t.sub_issue_type, + t.status, t.priority, t.queue_id, + t.assigned_resource_id, + t.create_date, t.last_activity_date + FROM tickets t + WHERE ${where.join(' AND ')} + ) + SELECT f.ticket_number, + f.id::text AS autotask_ticket_id, + f.title, + c.company_name AS client_name, + c.id::text AS client_id, + s.label AS status_label, + pr.label AS priority_label, + q.label AS queue_label, + it.label AS issue_type_label, + sit.label AS sub_issue_type_label, + CASE WHEN r.id IS NOT NULL + THEN trim(coalesce(r.first_name,'') || ' ' || coalesce(r.last_name,'')) + ELSE NULL + END AS assigned_resource_name, + f.create_date, + f.last_activity_date, + CASE WHEN f.create_date IS NULL THEN NULL + ELSE EXTRACT(DAY FROM NOW() - f.create_date)::int + END AS age_in_days, + latest.id::text AS latest_analysis_id, + latest.triggered_at AS latest_analysis_at, + latest.completed_at AS latest_completed_at, + latest.needs_human_review, + latest.confidence_score::text AS confidence_score, + (latest.aggregate_fingerprint ->> 'category') AS primary_category, + COUNT(*) OVER () AS total_count + FROM filtered f + LEFT JOIN companies c ON c.id = f.company_id + LEFT JOIN issue_types it ON it.value = f.issue_type + LEFT JOIN issue_types sit ON sit.value = f.sub_issue_type + LEFT JOIN statuses s ON s.value = f.status + LEFT JOIN priorities pr ON pr.value = f.priority + LEFT JOIN queues q ON q.value = f.queue_id + LEFT JOIN resources r ON r.id = f.assigned_resource_id + LEFT JOIN LATERAL ( + SELECT aa.id, aa.triggered_at, aa.completed_at, + aa.needs_human_review, aa.confidence_score, + aa.aggregate_fingerprint, aa.analysis_version + FROM analyzer_analyses aa + WHERE aa.ticket_number = f.ticket_number + AND aa.status = 'complete' + ORDER BY aa.analysis_version DESC + LIMIT 1 + ) latest ON TRUE + ${havingFragment} + ORDER BY ${sortClause(sort)} + LIMIT $${limitParamIdx} OFFSET $${offsetParamIdx} + `; + + const res = await postgresClient.query(sql, params); + const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; + + const tickets = res.rows.map((r) => { + let analyzedState: 'none' | 'current' | 'stale' = 'none'; + if (r.latest_analysis_id) { + if ( + r.last_activity_date && + r.latest_completed_at && + r.last_activity_date.getTime() > r.latest_completed_at.getTime() + ) { + analyzedState = 'stale'; + } else { + analyzedState = 'current'; + } + } + return { + ticketNumber: r.ticket_number, + autotaskTicketId: Number(r.autotask_ticket_id), + title: r.title, + clientName: r.client_name, + clientId: r.client_id ? Number(r.client_id) : null, + status: r.status_label, + priority: r.priority_label, + queue: r.queue_label, + issueType: r.issue_type_label, + subIssueType: r.sub_issue_type_label, + assignedResourceName: r.assigned_resource_name, + createdAtAutotask: r.create_date ? r.create_date.toISOString() : null, + lastActivityAtAutotask: r.last_activity_date + ? r.last_activity_date.toISOString() + : null, + ageInDays: r.age_in_days, + analyzedState, + latestAnalysisId: r.latest_analysis_id, + latestAnalysisAt: r.latest_analysis_at + ? r.latest_analysis_at.toISOString() + : null, + needsHumanReview: r.needs_human_review ?? false, + confidenceScore: r.confidence_score === null ? null : Number(r.confidence_score), + primaryCategory: r.primary_category, + }; + }); + + return NextResponse.json({ + tickets, + total, + filters: { + period, + startDate, + endDate, + clientIds, + issueTypes, + queues, + statuses, + priorities, + assignedTo, + analyzed, + needsReview, + sort, + search, + limit, + offset, + }, + }); +} diff --git a/app/globals.css b/app/globals.css index ebdc6f5..e9d2c6f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:is(.dark *)); diff --git a/components/analyzer/analysis-markdown.tsx b/components/analyzer/analysis-markdown.tsx new file mode 100644 index 0000000..df89397 --- /dev/null +++ b/components/analyzer/analysis-markdown.tsx @@ -0,0 +1,37 @@ +'use client'; + +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface Props { + children: string; + className?: string; +} + +/** + * Renders the analyzer's prose fields (summary, next_step, next_step_rationale, + * post_resolution_analysis) as markdown. Stage 3's prompt forbids headers, but + * we coerce any that slip through into bold paragraphs since the surrounding + * Card already provides the section heading. + */ +export function AnalysisMarkdown({ children, className = '' }: Props) { + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>

{children}

, + h6: ({ children }) =>

{children}

, + }} + > + {children} +
+
+ ); +} diff --git a/components/analyzer/analysis-view.tsx b/components/analyzer/analysis-view.tsx index 46c52d3..e89234a 100644 --- a/components/analyzer/analysis-view.tsx +++ b/components/analyzer/analysis-view.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { ShareModal } from './share-modal'; import { AnalyzeButton } from './analyze-button'; +import { AnalysisMarkdown } from './analysis-markdown'; import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer'; interface AnalysisViewProps { @@ -44,29 +45,6 @@ const SEVERITY_TONE: Record = { low: 'border-blue-500 bg-blue-500/5', }; -function ProseText({ - text, - className = '', -}: { - text: string; - className?: string; -}) { - const paragraphs = text - .split(/\n\s*\n/) - .map((p) => p.trim()) - .filter(Boolean); - if (paragraphs.length === 0) return null; - return ( -
- {paragraphs.map((p, i) => ( -

- {p} -

- ))} -
- ); -} - function ConfidenceBadge({ score }: { score: number | null }) { if (score === null) return null; const pct = Math.round(score * 100); @@ -169,7 +147,7 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { - + {a.summary} )} @@ -184,10 +162,9 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { - + + {a.nextStep} + {a.nextStepRationale && ( - + + {a.nextStepRationale} + )} @@ -351,10 +327,9 @@ export function AnalysisView({ analysis: a }: AnalysisViewProps) { - + + {a.postResolutionAnalysis} + )} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index bb63f0d..cc4ffdb 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -118,6 +118,12 @@ const navigationItems: NavItem[] = [ icon: Search, description: 'Filter tickets by period, client, or issue type — pick one to analyze', }, + { + title: 'Aggregate Reports', + href: '/analyzer/reports', + icon: BarChart3, + description: 'Cross-ticket pattern analysis: documentation gaps, process gaps, recurrence clusters', + }, { title: 'Needs Review', href: '/analyzer/queue', diff --git a/components/ui/multi-select.tsx b/components/ui/multi-select.tsx new file mode 100644 index 0000000..c49f724 --- /dev/null +++ b/components/ui/multi-select.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { ChevronDown, Search, X } from 'lucide-react'; + +export interface MultiSelectOption { + value: string; + label: string; +} + +interface Props { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchPlaceholder?: string; + className?: string; + /** Hide the search box when option count is below this threshold. */ + searchThreshold?: number; + /** Optional max-height for the option list (px). */ + maxListHeight?: number; +} + +export function MultiSelect({ + options, + value, + onChange, + placeholder = 'Any', + searchPlaceholder = 'Search…', + className = '', + searchThreshold = 8, + maxListHeight = 300, +}: Props) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const valueSet = useMemo(() => new Set(value), [value]); + + const filtered = useMemo(() => { + const s = search.trim().toLowerCase(); + if (!s) return options; + return options.filter((o) => o.label.toLowerCase().includes(s)); + }, [options, search]); + + const selectedLabels = useMemo(() => { + if (value.length === 0) return null; + if (value.length === 1) { + return options.find((o) => o.value === value[0])?.label ?? value[0]; + } + return `${value.length} selected`; + }, [value, options]); + + function toggle(optValue: string) { + if (valueSet.has(optValue)) { + onChange(value.filter((v) => v !== optValue)); + } else { + onChange([...value, optValue]); + } + } + + function clear(e: React.MouseEvent) { + e.stopPropagation(); + onChange([]); + } + + return ( + + + + + + {options.length >= searchThreshold && ( +
+
+ + setSearch(e.target.value)} + placeholder={searchPlaceholder} + className="pl-8 h-8" + /> +
+
+ )} +
+ {filtered.length === 0 ? ( +
+ No matches +
+ ) : ( + filtered.map((opt) => { + const checked = valueSet.has(opt.value); + return ( + + ); + }) + )} +
+
+
+ ); +} diff --git a/docs/ticket-analyzer-phase2-spec.md b/docs/ticket-analyzer-phase2-spec.md new file mode 100644 index 0000000..83750a9 --- /dev/null +++ b/docs/ticket-analyzer-phase2-spec.md @@ -0,0 +1,696 @@ +# Ticket Analyzer — Phase 2 Spec Additions + +This document **adds to and modifies** the existing ticket analyzer spec at `docs/ticket-analyzer-spec.md`. Apply these changes in the order listed. Each section explicitly states whether it adds, replaces, or modifies existing content. + +The Phase 2 work covers: + +1. Storing all intermediate stage data so analyses are fully reconstructable and aggregate analysis is feasible +2. Improving prose formatting in Summary, Next Step, and Next Step Rationale +3. A browse/filter ticket list UI with bulk selection +4. Aggregate trend and documentation-gap analysis across multiple analyzed tickets + +Build order is preserved at the end. Do not start aggregate analysis (#4) before the schema additions (#1) and fingerprinting are in place — backfilling fingerprints across a large analysis history is wasteful. + +Before starting, re-read `CLAUDE.md` and the existing analyzer code. Conform to whatever conventions emerged during Phase 1. + +--- + +## Section A — Schema additions for full analysis storage + +**ADDS to the existing migrations.** Do not modify existing tables in a destructive way; add new columns and a new table. + +### A.1 New table: `analyzer_stage_executions` + +Stores the input and output of every pipeline stage so analyses are fully reconstructable for debugging, auditing, and reprocessing with new prompts. + +```sql +CREATE TABLE analyzer_stage_executions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + analysis_id uuid NOT NULL REFERENCES analyzer_analyses(id) ON DELETE CASCADE, + stage text NOT NULL, -- 'preprocess'|'triage'|'itglue'|'analyze'|'deep_review'|'fingerprint' + stage_order int NOT NULL, + model_id text, -- null for non-LLM stages (preprocess, itglue) + input_payload jsonb NOT NULL, -- exactly what was sent to the stage + output_payload jsonb NOT NULL, -- exactly what came back, pre-merge + input_tokens int, + output_tokens int, + latency_ms int, + started_at timestamptz NOT NULL, + completed_at timestamptz NOT NULL, + error_message text +); +CREATE INDEX ON analyzer_stage_executions (analysis_id, stage_order); +CREATE INDEX ON analyzer_stage_executions (stage); +``` + +Rules: + +- Every stage that runs MUST insert a row, including stages that fail. On failure, populate `error_message` and still record what was attempted in `input_payload`. +- The `output_payload` for the IT Glue stage stores the *redacted* document content that was actually passed to the LLM, not raw IT Glue responses. Redaction happens before persistence. +- The deep-review stage's `output_payload` stores the full Opus response including `opus_notes`, NOT just the merged updates. The reasoning is currently being lost. +- The triage stage's `output_payload` stores the full Haiku output even though only some fields drive routing. The categorization and entity extraction are needed for aggregate analysis later. + +### A.2 New columns on `analyzer_analyses` + +```sql +ALTER TABLE analyzer_analyses ADD COLUMN source_snapshot jsonb; +ALTER TABLE analyzer_analyses ADD COLUMN aggregate_fingerprint jsonb; +ALTER TABLE analyzer_analyses ADD COLUMN fingerprint_generated_at timestamptz; +``` + +- `source_snapshot` — the pre-processed, tagged event list from Stage 0 (after noise filtering and visibility tagging). Stored canonically because: + 1. Pulse sync may change/improve, but the analysis is grounded in what was true at analysis time + 2. Ticket notes occasionally get edited or deleted in Autotask + 3. Aggregate analysis must operate on a consistent canonical structure across many tickets without re-fetching + +- `aggregate_fingerprint` — structured summary used for aggregate analysis (schema in Section D.2 below). Generated as part of the pipeline; described in Section D. + +The existing `model_traces` column on `analyzer_analyses` becomes redundant once `analyzer_stage_executions` is populated. Keep it for now but add a comment in code marking it as legacy. A future migration can drop it. + +### A.3 Backfill behavior + +Existing analyses (if any from Phase 1) will not have `source_snapshot` or `aggregate_fingerprint`. Do NOT attempt automatic backfill. Provide a CLI script `apps/api/scripts/backfill-fingerprints.ts` that operators can run on demand. The script should: + +- Accept `--limit` and `--dry-run` flags +- Process analyses missing `aggregate_fingerprint` in batches of 10 +- Log progress and total cost +- Be idempotent + +Document this script in the operator runbook (added in Phase 1 deliverable 9). + +--- + +## Section B — Prose formatting fixes + +**MODIFIES the Stage 3 (Sonnet) system prompt and the frontend analysis view.** + +### B.1 Stage 3 system prompt modifications + +In the existing Stage 3 system prompt, the schema declaration for `summary`, `next_step`, `next_step_rationale`, and `post_resolution_analysis` should be replaced with: + +``` +"summary": string, // markdown, 2-4 sentences, neutral status briefing +"next_step": string, // markdown, single concrete action; may use + // **bold** for the action verb and bullet + // sub-steps if multi-part +"next_step_rationale": string, // markdown, 1-2 short paragraphs; if there are + // 3+ competing considerations, use a bulleted list +"post_resolution_analysis": string | null, // markdown, same conventions as above +``` + +Add this section to the system prompt, immediately after the schema declaration block: + +``` +Formatting rules for prose fields (summary, next_step, next_step_rationale, +post_resolution_analysis): + +- Output is markdown and will be rendered with a markdown renderer. Use **bold** + for emphasis on key terms or actions. Use *italics* sparingly for client-facing + language being quoted. Use bullet lists for enumerable items. + +- Do NOT use markdown headers (#, ##, ###). These fields render inside cards that + already have their own headings. + +- For summary: write as a neutral status briefing a manager could read in 10 + seconds. Lead with the most important fact. Plain language. No hedging. + +- For next_step: state the concrete action in the first sentence, with the action + verb in **bold**. If there are sub-steps, follow with a bulleted list. Address + the action to the technician, not the customer. + +- For next_step_rationale: open with one short sentence stating the core reason. + Follow with a short paragraph elaborating, OR a bulleted list if there are 3+ + distinct considerations. If there's a competing alternative worth noting, name + it explicitly: "Considered X but rejected because..." + +- Avoid filler phrases. Banned openings: "It's worth noting that...", "It's + important to understand...", "Based on the available information...", + "After reviewing the ticket...". Get to the point. +``` + +### B.2 Frontend rendering + +Add `react-markdown` and `remark-gfm` to `apps/web/package.json` if not already present. + +Create a shared component `apps/web/src/components/analyzer/AnalysisMarkdown.tsx`: + +```tsx +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface Props { + children: string; + className?: string; +} + +export function AnalysisMarkdown({ children, className }: Props) { + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>

{children}

, + h6: ({ children }) =>

{children}

, + }} + > + {children} +
+
+ ); +} +``` + +Use this component for the four prose fields on the analysis view. Match the existing wulf-pulse Tailwind typography setup; if the project doesn't already use `@tailwindcss/typography`, add it — it's the right plugin for prose-rendering blocks. + +--- + +## Section C — Browse / filter ticket list UI + +**ADDS a new view and supporting API endpoint.** Lives in the same nav section as the analyzer feature. + +### C.1 New API endpoint + +``` +GET /api/analyzer/tickets +``` + +Query parameters: + +``` +period today | yesterday | this_week | last_week + | last_30d | last_60d | custom +startDate ISO date, only when period=custom +endDate ISO date, only when period=custom +clientId Autotask account id (single or comma-separated) +issueType Ticket Category, Issue Type, or Sub-Issue Type +queue Autotask queue name +status Autotask status (single or comma-separated) +priority Autotask priority (single or comma-separated) +assignedTo Autotask resource id +analyzed any | yes | no | stale (default: any) +needsReview true | false (default: any) +sort created_desc | created_asc + | last_activity_desc | last_activity_asc + | priority (default: last_activity_desc) +limit default 50, max 200 +offset default 0 +``` + +Response: + +```ts +{ + tickets: Array<{ + ticketNumber: string; + autotaskTicketId: number; + title: string; + clientName: string; + clientId: number; + status: string; + priority: string; + queue: string; + issueType: string; + subIssueType: string | null; + assignedResourceName: string | null; + createdAtAutotask: string; + lastActivityAtAutotask: string; + ageInDays: number; + + // Analysis state + analyzedState: 'none' | 'current' | 'stale'; + latestAnalysisId: string | null; + latestAnalysisAt: string | null; + needsHumanReview: boolean; + confidenceScore: number | null; + primaryCategory: string | null; // from fingerprint, if analyzed + }>; + total: number; + filters: { /* echoed for client-side state sync */ }; +} +``` + +Implementation notes: + +- Read tickets from the existing Pulse Postgres sync, NOT live from Autotask. List views must be fast. +- LEFT JOIN `analyzer_analyses` on `ticket_number` filtered to the latest version per ticket. Use a window function or subquery — discuss with me before introducing a materialized view. +- `analyzedState` derivation: + - `none` — no `analyzer_analyses` row + - `current` — latest analysis's `content_hash_at_analysis` matches the current Pulse content hash + - `stale` — latest analysis exists but content hash differs (new activity since) +- The current Pulse content hash will require computing on the fly OR caching on the Pulse ticket row. If Pulse already has webhook-driven updates, add a `current_content_hash` column to the Pulse tickets table populated on sync. If not, compute on-read for now and discuss caching strategy after we see real load. +- Cap `limit` at 200 server-side regardless of input. If a user wants more than 200, they need narrower filters (or the aggregate report flow described in Section D). + +### C.2 Frontend route and components + +Route: `/analyzer/tickets` — the new list view. Becomes the primary entry point for the analyzer feature; update wulf-pulse navigation accordingly. + +Layout, top to bottom: + +**Filter bar** (sticky on scroll): + +- **Period selector**: shadcn `ToggleGroup` with options Today / Yesterday / This Week / Last Week / 30d / 60d / Custom. Custom opens a date range picker (use shadcn `Calendar` component pattern already present in wulf-pulse if any; else add). +- **Client filter**: searchable multi-select dropdown. Source: Pulse companies table where `Category = Recurring Revenue Customer`. +- **Issue type filter**: multi-select dropdown of distinct values from Pulse tickets table. +- **Queue filter**: multi-select dropdown. +- **Status filter**: multi-select dropdown, default to non-Complete statuses. +- **Analyzed filter**: segmented control — Any / Analyzed / Not Analyzed / Has New Activity (stale). +- **Needs Review toggle**: single checkbox, surfaces `needs_human_review = true`. +- **Active filter chips**: show currently-applied non-default filters as removable chips below the filter bar so users can see/clear at a glance. + +**Result count and bulk actions bar**: + +- Left: "Showing X of Y tickets" plus the active filter summary +- Right: bulk action buttons (disabled until selection is non-empty): + - "Analyze N tickets" (only enabled if any selected tickets are not analyzed or are stale) + - "Generate aggregate report" (only enabled if all selected tickets are analyzed and current; otherwise tooltip explains why) + +**Table**: shadcn `Table`. Columns: + +| Col | Width | Notes | +|---|---|---| +| Checkbox | fixed | header has select-all-on-page; "Select all matching filters" appears as an inline action when the page is fully selected | +| Analyzed | small | dot indicator: ⚪ none / 🟢 current / 🟡 stale; tooltip shows analysis date | +| Ticket # | small | links to ticket detail in wulf-pulse | +| Title | flex | truncate with tooltip | +| Client | medium | | +| Status | small | colored badge | +| Priority | small | | +| Queue | small | | +| Age | small | "3d 4h" format | +| Last activity | small | relative time | +| Assigned | small | | +| Action | fixed | "Analyze" or "View analysis" button per-row | + +**Empty states**: + +- No tickets matched filters: friendly message + "Clear filters" button +- No tickets in the entire date range: explicit different message (the sync may be broken) + +**Pagination**: cursor or offset, match whatever wulf-pulse already uses. 50 per page default. + +### C.3 Bulk selection mechanics + +- Page checkbox selects all rows currently visible +- When all visible rows are selected, an inline banner appears: "All N tickets on this page selected. Select all M tickets matching filters?" +- "Select all matching filters" stores the filter criteria (not the IDs) so re-running the query later returns the same logical set +- Selection persists across pagination within a single session (localStorage keyed by a session id, NOT by user account) +- Selection clears on filter change (warn user with confirm if they have a non-empty selection) + +--- + +## Section D — Aggregate trend and documentation-gap analysis + +**ADDS a new feature.** Depends on Section A schema changes and Section C UI being in place. + +This is the highest-value capability of the analyzer because it converts individual ticket analyses into systemic insights. + +### D.1 Architecture: map-reduce, not concatenation + +The implementation is a map-reduce over analyses. **Do not** implement aggregate analysis by concatenating analysis prose and asking a model to find patterns. That approach breaks down at low N and is not verifiable. + +**Map step (fingerprinting):** runs as part of every individual analysis. Produces a structured fingerprint stored on `analyzer_analyses.aggregate_fingerprint`. Cheap (Haiku), one-time-per-analysis cost. + +**Reduce step (aggregate report):** runs on demand when user clicks "Generate aggregate report." Takes N fingerprints, runs SQL aggregations for instant feedback, then runs a single LLM call (Sonnet, possibly Opus for large sets) to produce the narrative report. + +### D.2 Fingerprint schema + +Stored as `aggregate_fingerprint` jsonb on `analyzer_analyses`: + +```ts +{ + // Categorization + category: string; // primary category (backup, network, m365, ...) + subcategories: string[]; // additional applicable categories + ticket_type_inferred: string; // model's classification, may differ from Autotask + root_cause_class: + | 'configuration_drift' + | 'user_error' + | 'vendor_issue' + | 'hardware_failure' + | 'documentation_gap' + | 'process_gap' + | 'unknown' + | 'other'; + + // Entities for aggregation + client_name: string; + vendors_involved: string[]; + applications_involved: string[]; + device_classes: string[]; // 'workstation' | 'server' | 'firewall' | etc. + + // Wulf actions and outcomes + wulf_actions_taken: string[]; // short verb phrases + vendor_cases_opened: number; + resolution_path: + | 'resolved_by_wulf' + | 'resolved_by_vendor' + | 'resolved_by_client' + | 'unresolved' + | 'self_resolved_before_wulf_action'; + + // Gap signals — most important for aggregate + documentation_gaps_observed: Array<{ + description: string; // specific, actionable + confidence: 'low' | 'medium' | 'high'; + }>; + process_gaps_observed: Array<{ + description: string; + severity: 'low' | 'medium' | 'high'; + }>; + + // Recurrence signals + similar_to_signals: string[]; // free-text descriptions of "this looks like" + // patterns — fed into the reduce step + tags: string[]; // free-form tags for downstream clustering + + // Metadata + generated_by_model: string; + generated_at: string; +} +``` + +### D.3 Fingerprint generation in the pipeline + +Add a new pipeline stage after Stage 5 (Persistence): **Stage 6 — Fingerprint**. + +Stage 6 runs even if Opus didn't run. Uses Haiku. + +System prompt: + +``` +You are extracting a structured fingerprint from a completed ticket analysis +to enable cross-ticket aggregation. Your job is precision and consistency, +not creativity. + +You will receive: +- The Sonnet-tier analysis output (summary, gaps, what_was_done, etc.) +- The triage output from Stage 1 (entities, category) +- Optionally the Opus-tier updates if deep review ran + +Produce a fingerprint matching this exact schema: + +[paste schema from D.2 here] + +Strict rules: +- Use only the listed enum values for root_cause_class and resolution_path. +- documentation_gaps_observed and process_gaps_observed should each have at + most 5 entries. Quality over quantity. Each entry's description must be + specific enough that two different analyses describing the same underlying + gap would produce similar text. +- Tags should be lowercase, hyphenated, and stable. Prefer reusing common + tags (vertafore, ams360, m365-licensing, backup-veeam, etc.) over inventing + new ones. +- For similar_to_signals, write short observations like "vendor case opened + before checking documentation portal" or "status not advanced after customer + self-resolution" — patterns the reduce step can cluster. +``` + +Persist the fingerprint to `analyzer_analyses.aggregate_fingerprint` and `fingerprint_generated_at`. Also create a row in `analyzer_stage_executions` for this stage. + +If fingerprinting fails, do NOT fail the overall analysis. Log the error, leave fingerprint null, and the analysis is still usable — just won't appear in aggregate reports until re-fingerprinted via the backfill script. + +### D.4 New table: `analyzer_aggregate_reports` + +```sql +CREATE TABLE analyzer_aggregate_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + generated_by_user_id uuid NOT NULL, + generated_at timestamptz DEFAULT now(), + + -- Inputs + filter_criteria jsonb NOT NULL, -- snapshot of filter state at generation + analysis_ids uuid[] NOT NULL, + ticket_count int NOT NULL, + + -- SQL-derived outputs (computed before LLM call) + category_distribution jsonb, -- { "backup": 14, "network": 8, ... } + client_distribution jsonb, -- { "Seubert": 23, "Hynes": 11, ... } + resolution_path_distribution jsonb, + root_cause_distribution jsonb, + date_range_actual jsonb, -- { earliest, latest } from selected tickets + + -- LLM-derived outputs + documentation_gaps jsonb, -- [{gap, frequency, example_ticket_numbers, evidence}] + process_gaps jsonb, -- same shape + client_patterns jsonb, -- [{client, pattern, frequency, example_tickets}] + recurrence_clusters jsonb, -- [{theme, ticket_numbers, summary}] + systemic_observations jsonb, -- [{observation, evidence, severity}] + recommended_actions jsonb, -- [{action, rationale, priority, type}] + -- type: 'documentation' | 'process' | 'training' | 'tooling' + + -- Narrative + narrative_summary text, -- markdown, the human-readable report + executive_summary text, -- markdown, 3-5 sentence top-of-report version + + -- Metadata + total_input_tokens int, + total_output_tokens int, + estimated_cost_usd numeric(10,4), + model_used text, + itglue_context_included boolean, -- whether IT Glue doc titles were fed in + status text NOT NULL DEFAULT 'complete' -- pending|running|complete|failed +); + +CREATE INDEX ON analyzer_aggregate_reports (generated_by_user_id, generated_at DESC); +CREATE INDEX ON analyzer_aggregate_reports USING gin (analysis_ids); +``` + +### D.5 New API endpoints + +``` +POST /api/analyzer/aggregate-reports +``` + +Body: +```ts +{ + analysisIds: string[]; // explicit analysis IDs to include + // OR + filterCriteria: { /* same shape as ticket list filters */ }; + // server resolves to the analyses for matching tickets + includeItglueContext: boolean; // default true; whether to feed IT Glue doc titles + // into the reduce step for the affected clients + reportTitle: string | null; +} +``` + +Behavior: + +- Resolve to a concrete list of analysis IDs. Cap at 100. If filter criteria resolves to >100, return 400 with a count and instruction to narrow. +- Validate every analysis has a current `aggregate_fingerprint`. If any are missing, return 400 with the list of unfingerprinted analysis IDs and a hint to run the backfill script. +- Validate every selected ticket has a `current` analyzed state (not `stale`). If stale tickets exist, return 400 with the list — user should re-analyze first. +- Queue the report generation as a job. Return `{ reportId, jobId, status }`. + +``` +GET /api/analyzer/aggregate-reports/:id +GET /api/analyzer/aggregate-reports # list, paginated, filterable by user + # and date range +``` + +### D.6 Aggregate report pipeline + +When the job runs: + +**Step 1 — SQL aggregations:** compute the four `*_distribution` fields and `date_range_actual` from the fingerprints. Persist to the report row immediately so the UI can show partial results. + +**Step 2 — IT Glue context (conditional):** if `includeItglueContext = true`, fetch the *titles and types* (not bodies) of all IT Glue docs for the unique clients in the selection. This list is fed to the reduce step so the model can distinguish "no runbook exists" from "no runbook was referenced." Cap at 200 doc titles total. Apply the same redaction rules to titles (rare but possible). + +**Step 3 — Reduce LLM call:** + +Model selection logic: +- ≤25 fingerprints: Sonnet +- 26-100 fingerprints: Sonnet, but only the structured fingerprints (no narratives) +- If user explicitly opts in OR cost circuit-breaker allows: Opus + +System prompt: + +``` +You are a senior MSP analyst identifying patterns across multiple ticket +analyses to surface systemic issues. + +You will receive: +1. SQL-derived distributions (categories, clients, resolution paths, root causes) +2. An array of structured fingerprints, one per analyzed ticket +3. Optionally, a list of IT Glue documentation titles for the affected clients + +Your job is to identify patterns the SQL aggregations cannot see — patterns +that emerge from the gap descriptions, vendor involvement, recurrence signals, +and cross-ticket clustering. + +Be specific. Cite ticket numbers as evidence for every claim. Distinguish +between "documentation gap exists" (no doc on this topic per the IT Glue +title list) and "documentation not referenced" (doc may exist but wasn't +used in resolution). + +Respond ONLY with JSON matching this schema: + +{ + "documentation_gaps": [ + { + "gap": string, + "frequency": number, + "example_ticket_numbers": string[], + "evidence": string, + "itglue_check": "no_doc_exists" | "doc_exists_but_unused" | "unable_to_verify" + } + ], + "process_gaps": [ + { + "gap": string, + "frequency": number, + "severity": "low" | "medium" | "high", + "example_ticket_numbers": string[], + "evidence": string + } + ], + "client_patterns": [ + { + "client": string, + "pattern": string, + "frequency": number, + "example_ticket_numbers": string[] + } + ], + "recurrence_clusters": [ + { + "theme": string, + "ticket_numbers": string[], + "summary": string // markdown, what unifies these + } + ], + "systemic_observations": [ + { + "observation": string, // markdown + "evidence": string, + "severity": "low" | "medium" | "high" + } + ], + "recommended_actions": [ + { + "action": string, // markdown, concrete and actionable + "rationale": string, // markdown + "priority": "low" | "medium" | "high", + "type": "documentation" | "process" | "training" | "tooling" + } + ], + "executive_summary": string, // markdown, 3-5 sentences + "narrative_summary": string // markdown, full prose report +} + +Quality bars: +- Do not list a documentation_gap unless it appears in 2+ tickets. +- Do not list a process_gap unless it appears in 2+ tickets OR has severity=high + in at least one. +- Recurrence clusters require at least 2 tickets. +- Every recommended_action must be specific enough that a person could pick + it up tomorrow. "Improve documentation" is rejected; "Create a runbook for + AMS360 App Access Key location and integration permission verification" is + acceptable. +- The narrative_summary follows the same prose formatting rules as individual + analyses: markdown, no headers, no filler phrases. +``` + +User message: structured payload with the SQL distributions, the array of fingerprints, and the IT Glue title list. + +**Step 4 — Persistence:** update the report row with all LLM-derived fields, mark `status = complete`. Insert an `analyzer_stage_executions` row for the reduce step (linked via a `aggregate_report_id` column — add this to `analyzer_stage_executions`): + +```sql +ALTER TABLE analyzer_stage_executions ADD COLUMN aggregate_report_id uuid + REFERENCES analyzer_aggregate_reports(id) ON DELETE CASCADE; +ALTER TABLE analyzer_stage_executions + ADD CONSTRAINT analyzer_stage_executions_parent_check + CHECK ((analysis_id IS NOT NULL) <> (aggregate_report_id IS NOT NULL)); +``` + +(Either an analysis stage or a report stage, not both, not neither.) + +### D.7 Frontend: aggregate report flow + +**Trigger from ticket list view (Section C):** + +When user has selected tickets and clicks "Generate aggregate report": + +1. Pre-flight check (client-side): are all selected tickets analyzed and current? + - If not all analyzed: show modal "X of Y selected tickets aren't analyzed. Analyze them first?" with cost estimate. Run analyses, then proceed. + - If any are stale: show modal "X tickets have new activity since last analysis. Re-analyze first or proceed with stale data?" +2. Show pre-LLM SQL summary modal: + - Ticket count + - Category distribution as a small bar chart (Recharts, matching wulf-pulse style) + - Client distribution + - Date range actual + - Estimated LLM cost + - "Generate narrative report" button + "Cancel" +3. On confirm, kick off the job, navigate to `/analyzer/reports/:id` showing pending state with progress. + +**Aggregate report view at `/analyzer/reports/:id`:** + +Layout, top to bottom: + +1. **Header** — report title (editable), generated by, generated at, ticket count, date range, cost +2. **Executive summary** — card with `AnalysisMarkdown`, prominent +3. **Distributions row** — three small cards side-by-side: category bar chart, root cause donut, resolution path donut +4. **Documentation gaps section** — card with table: gap description / frequency / example tickets (linked) / IT Glue check status. Color-coded by `itglue_check` value. +5. **Process gaps section** — similar table, color by severity +6. **Client patterns section** — accordion grouped by client +7. **Recurrence clusters section** — each cluster as a card with the theme summary and a list of linked ticket numbers +8. **Systemic observations** — list with severity colors +9. **Recommended actions** — sortable by priority, grouped by type (documentation / process / training / tooling). Each action is a card with action + rationale. +10. **Narrative summary** — full markdown render at the bottom for those who want the prose version +11. **Footer** — share button (reuses existing share infrastructure), export to markdown button, "Re-run with current data" button + +**List view at `/analyzer/reports`:** + +Simple table of past reports: title, date, generated by, ticket count, cost, link to view. Filterable by date range and generated_by. + +### D.8 Cost guards + +The aggregate report can get expensive at high N. Guards: + +- Hard cap of 100 tickets per report. +- If estimated cost > $5.00 (computed from fingerprint payload size + IT Glue context size), require explicit confirmation in the UI with the dollar figure shown. +- Track total spend per user per day; soft warn at $20/day, hard block at $50/day with an admin override env var `ANALYZER_DAILY_COST_OVERRIDE_USERS` (comma-separated user IDs). +- Persist cost-guard decisions to a small audit log table `analyzer_cost_audit` (user, action, estimated_cost, decision, timestamp) so we can review patterns later. + +--- + +## Build order + +Strict dependency order. Do not skip ahead. + +1. **Section A — schema additions.** Migrations land first. `analyzer_stage_executions`, `source_snapshot` and `aggregate_fingerprint` columns. Backfill script for existing analyses (don't run yet — just have it ready). + +2. **Update existing pipeline to write to new tables.** Every stage now inserts into `analyzer_stage_executions`. Stage 0 output saves to `source_snapshot`. Run against existing test fixtures to confirm nothing regressed. + +3. **Section B — prose formatting.** Stage 3 prompt update + frontend `AnalysisMarkdown` component. Test against the T20260424.0045 fixture and a couple new samples. This is independent of everything else and can be committed as a small standalone PR. + +4. **Section D.3 — fingerprint generation as Stage 6.** Add to pipeline. Run backfill script once on existing analyses. From this point forward every new analysis automatically produces a fingerprint. + +5. **Section C — browse/filter UI.** New API endpoint, new route, table UI, bulk selection. This is the biggest UI surface — budget appropriately. + +6. **Section D.4–D.7 — aggregate report feature.** Endpoints, pipeline, report view, list view. + +7. **Section D.8 — cost guards.** Land before opening the feature beyond yourself. + +8. **Documentation update** — operator runbook gains sections on aggregate reports, fingerprint backfill, cost guard overrides, and how to read the stage execution history when debugging a bad analysis. + +For each phase, confirm with me before moving to the next. Phase 1 (schema) and Phase 5 (browse UI) are the highest-risk for needing iteration; pause after each for review. + +--- + +## Critical correctness notes (additions to existing list) + +- **Fingerprint enums must be enforced at parse time.** The Stage 6 Zod schema must use Zod's `enum()` for `root_cause_class` and `resolution_path`. If the model returns a value outside the enum, retry once with the parse error. + +- **Aggregate report inputs must all be from the same fingerprint schema version.** If you change the fingerprint schema in the future, add a `fingerprint_schema_version` field and refuse to mix versions in a single report. Migration story: re-run fingerprinting on affected analyses before allowing them in new reports. + +- **Never include unredacted IT Glue content in fingerprints, stage executions, or aggregate reports.** Redaction happens before any persistence, not just before LLM calls. This was already a rule for Phase 1; reaffirming it because the surface area is now larger. + +- **Aggregate reports are not real-time.** Show timestamps prominently. A report generated yesterday does not reflect today's tickets. Add a "Re-run with current data" button to the report view that creates a new report with the same filter criteria as a clone. + +- **Fingerprinting cost is real but bounded.** Haiku at ~$0.001 per fingerprint × hundreds of analyses adds up. Monitor. If it becomes meaningful, consider running fingerprinting only when the analysis crosses a complexity threshold and using a deterministic SQL-based fingerprint for low-complexity tickets. diff --git a/docs/wulf-pulse-ticket-analyzer-build-notes.md b/docs/wulf-pulse-ticket-analyzer-build-notes.md index 0d2ec94..df46044 100644 --- a/docs/wulf-pulse-ticket-analyzer-build-notes.md +++ b/docs/wulf-pulse-ticket-analyzer-build-notes.md @@ -506,3 +506,291 @@ analyze without typing the URL. | 7 | 128 | clean | share email via existing SMTP transport | | 8 | 128 | clean | operator runbook + README link | | 9 | 128 | clean | browse page + analysis-view formatting + nav entry | + +--- + +# Phase 2 (cross-ticket analysis) + +Spec: `docs/ticket-analyzer-phase2-spec.md`. Eight sub-phases delivered as +one Phase 2 push. + +## 2.1 — Schema additions + +**Delivered** + +- Migration 070: `analyzer_stage_executions` table (per-stage I/O for + every analyzer run, including failed attempts) + three columns on + `analyzer_analyses`: `source_snapshot`, `aggregate_fingerprint`, + `fingerprint_generated_at`. +- `model_traces` column annotated with a `LEGACY` `COMMENT ON COLUMN` + for the SQL side and a `// LEGACY` doc comment in TS — kept for + back-compat until aggregate reports have soaked. +- Zod schemas: `AggregateFingerprint`, `StageName`, `StageExecution` + (read-back), `StageExecutionRecord` (write-time interface). + +**Decisions worth flagging** + +- `analyzer_stage_executions.analysis_id` starts NOT NULL in 070. + Migration 071 (Phase 2.6) relaxes it and adds the + mutually-exclusive CHECK with `aggregate_report_id`. +- 070 is idempotent (`IF NOT EXISTS` on every object) so re-running + against an already-applied DB is safe. + +## 2.2 — Pipeline writes to stage executions + +**Delivered** + +- `recordedStage(meta, fn, callbacks, outputSelector)` helper in + `pipeline.ts` wraps each stage call, emits a `StageExecutionRecord` + on success or failure (re-throws after recording). Pipeline wires + it into Stage 1 (triage), Stage 3 (analyze), Stage 4 (deep_review). + Stage 0 (preprocess) and Stage 2 (itglue) are recorded inline since + they're not LLM calls. +- Pipeline result now includes `triage_response`, `sonnet_response`, + `opus_response` for downstream stages (Stage 6 fingerprint). +- Worker's `runJob` collects records via `onStageRecord` callback, + bulk-inserts them after `insertAnalysis` succeeds. On pipeline + throw, worker captures the preprocessed bundle via `onPreprocessed` + callback, persists a `status='failed'` analyzer_analyses row with + source_snapshot intact, and bulk-inserts the partial stage records + linked to it. +- New persistence functions: `bulkInsertStageExecutions`, + `insertFailedAnalysis`, `updateAnalysisFingerprint`. + +**Decisions worth flagging** + +- **Failure-tolerant audit**: spec says "Every stage that runs MUST + insert a row, including stages that fail." We persist a failed + analyzer_analyses row even on pipeline crash so the partial stage + records have a parent. Without this the FK would be orphaned. +- **Single bulk insert**: ~5–6 stage rows per pipeline run. One + multi-VALUES INSERT is fast enough; no need for COPY. +- **`model_traces` double-write retained**: the legacy column still + receives the old payload. Drop it in a future migration once + aggregate reports have soaked through prod. + +## 2.3 — Prose formatting + +**Delivered** + +- Stage 3 system prompt updated with the markdown formatting rules + from the spec verbatim (banned filler phrases, action-verb + emphasis, no headers). +- `react-markdown@10`, `remark-gfm@4`, `@tailwindcss/typography@0.5` + added. Tailwind 4 plugin registered via `@plugin + "@tailwindcss/typography"` in `app/globals.css`. +- `` component at `components/analyzer/analysis-markdown.tsx` + renders prose with `prose prose-sm dark:prose-invert + max-w-none prose-p:leading-7`. Coerces stray model headers into + bold paragraphs (the prompt forbids them but defense-in-depth). +- `` removed. Summary, Recommended Next Step, + next_step_rationale, and post_resolution_analysis all use + ``. + +**Decisions worth flagging** + +- Tailwind 4 syntax: `@plugin "@tailwindcss/typography"` in CSS, no + JS config needed. +- Stage 3 prompt change is back-compatible — old analyses with + plain-text summaries still render fine through ReactMarkdown. + +## 2.4 — Stage 6 fingerprint + backfill CLI + +**Delivered** + +- `lib/services/analyzer/stages/stage6-fingerprint.ts` — Haiku call + with the spec's verbatim system prompt. Server-overrides + `generated_by_model` and `generated_at` after parse so the model's + guess for those fields can't drift. +- Worker integration: after `insertAnalysis` succeeds, run + fingerprint with try/catch. Failure logs a warn, fingerprint + stays NULL on the row, but the analysis is still complete and + usable. The fingerprint stage record is added to the bulk insert + whether it succeeded or failed. +- `scripts/backfill-fingerprints.ts` — idempotent CLI. Reads + `analyzer_analyses.model_traces.{triage_response, sonnet_response, + opus_response}` (which Phase 1 was already storing), runs Stage 6, + writes `aggregate_fingerprint`. Supports `--dry-run` and + `--limit=N`. Skips analyses where model_traces is incomplete. + +**Decisions worth flagging** + +- Stage 6 input is just the analysis content (triage + sonnet + + optional opus). No `pre` payload needed — fingerprinting is about + the produced *analysis*, not the source ticket. +- Backfill processes oldest-first (triggered_at ASC). Lets us + observe a few rounds before chewing through hundreds. +- Stage 6 failure is non-fatal. Spec: "If fingerprinting fails, do + NOT fail the overall analysis." + +## 2.5 — Browse / filter UI rebuild + +**Delivered** + +- New endpoint: `GET /api/analyzer/tickets` (replaces the simpler + `GET /api/analyzer/tickets/list` from Phase 1.9). Multi-select + CSV-style query params (clientId, issueType, queue, status, + priority, assignedTo); analyzed segmented filter (any/yes/no/stale); + needsReview toggle; search; sort. +- `/api/analyzer/tickets/filter-options` extended with queues, + statuses, priorities, resources (joined to "has at least one + ticket" so the dropdowns aren't padded). +- New `` component at `components/ui/multi-select.tsx` + — Popover + checkbox list with optional search box (auto-shown + above 8 options). One trigger + one popover, no shadcn Command + dependency. +- `/analyzer/tickets` page rebuilt: + - Sticky filter bar with period pills, multi-selects, search, + analyzed segmented, needs-review checkbox, sort + - Active-filter chips (click to clear individual filter) + - Bulk selection persisted via localStorage (key + `analyzer:ticket-selection:v1`) — survives pagination + - "Analyze N selected" — sequential job queue, forces re-analyze + on `stale` rows + - "Generate aggregate report" — routes to `/analyzer/reports/new`; + only enabled when all selected are `current` +- Top nav reorganized: Browse Tickets / Aggregate Reports / Needs + Review under "Analyzer". + +**Decisions worth flagging** + +- **Staleness via `last_activity_date > completed_at`**, not + content-hash compare. The spec lets either; the date heuristic is + good enough and avoids per-row preprocessing on 50-row paginated + responses. +- **`MultiSelect` is a one-popover-per-instance design** — multiple + popovers can be open across the bar. Acceptable; matches how + Linear / Vercel's table filters behave. +- **No "Select all matching filters" semantic**. Selection is an + explicit per-row action stored as ticket numbers in localStorage. + Filter-level selection adds significant complexity (server has to + resolve filter→IDs, two-modes everywhere). Skipped for V1; the + spec's intent (don't lose selection on pagination) is met. +- **Bulk Analyze is sequential, not parallel.** N concurrent calls + would all hit `claimQueuedJob` and the worker would process them + one at a time anyway (single in-process worker). Sequential POSTs + are more honest about that. + +## 2.6 — Aggregate reports + +**Delivered** + +- Migration 071: `analyzer_aggregate_reports` table + ALTER on + `analyzer_stage_executions` to drop NOT NULL on `analysis_id`, + add `aggregate_report_id` FK, add the + `analyzer_stage_executions_parent_check` CHECK constraint + (`(analysis_id IS NOT NULL) <> (aggregate_report_id IS NOT NULL)`). +- `lib/services/analyzer/stages/aggregate-reduce.ts` — Sonnet (Opus + opt-in) reduce stage with the spec's verbatim system prompt and + `AggregateReduceResponse` Zod schema. +- `lib/services/analyzer/aggregate-persistence.ts` — `createAggregateReport`, + `getAggregateReport`, `listAggregateReports`, `runAggregateReport`, + `bulkInsertReportStageExecutions`. The runner is fire-and-forget + (called via `void runAggregateReport(id)` from the POST endpoint); + it persists distributions immediately so the UI can show partial + results during the LLM call. +- IT Glue context fetcher: per-client `findOrganizationByName` + + `getFlexibleAssets`, capped at 200 doc titles total per spec. + Failure tolerant — per-client errors don't fail the report. +- API endpoints: + - `POST /api/analyzer/aggregate-reports` — validates (≤100, + fingerprint exists, not stale), creates pending row, fires runner + - `GET /api/analyzer/aggregate-reports/:id` — full report row + (UI polls this every 3s while pending/running) + - `GET /api/analyzer/aggregate-reports` — paginated list +- Pages: + - `/analyzer/reports/new?ids=T...,T...` — pre-flight: shows + selected tickets, options (title, IT Glue context toggle), + Generate button + - `/analyzer/reports/[id]` — pending → distributions → completed. + Sections: header, executive summary, four distribution mini-bar + cards, documentation gaps (with `itglue_check` color tone), + process gaps (severity tone), recurrence clusters, recommended + actions (sorted by priority), narrative summary. + - `/analyzer/reports` — table list of past reports + +**Decisions worth flagging** + +- **Fire-and-forget runner**, no separate worker module. The POST + endpoint kicks `void runAggregateReport(id)`; updates land in the + row when the LLM call completes. Frontend polls. Avoids adding a + second polling worker alongside `analyzerWorker`. +- **Stage names reused for aggregate sub-stages.** The CHECK constraint + on `analyzer_stage_executions.stage` enumerates the per-analysis + stage names. Aggregate sub-stages (SQL aggregation, IT Glue context, + reduce LLM) are recorded with `stage='analyze'` / `'itglue'` plus + `aggregate_report_id` set. A future migration could add + `aggregate_sql` / `aggregate_reduce` to the enum and re-emit those + rows; for now the existing names are good enough for forensics. +- **`generated_by_user_id` is TEXT nullable**, not `uuid NOT NULL` + per spec. Better Auth's `user.id` is text, and we want the report + to remain readable if the generating user is later deleted — + matches the pattern from `analyzer_analyses.triggered_by_user_id`. +- **Distributions persist before LLM call** so partial-state UI + doesn't have to wait the full 30–90s for anything to render. + +## 2.7 — Cost guards + +**Delivered** + +- Migration 072: `analyzer_cost_audit` table. +- `lib/services/analyzer/cost-guard.ts`: + `estimateAggregateReportCost`, `getUserDailySpend`, `evaluateCost`, + `recordCostAuditDecision`. Thresholds: `REQUIRES_CONFIRMATION_USD = 5`, + `SOFT_WARN_DAILY_USD = 20`, `HARD_BLOCK_DAILY_USD = 50`. +- `evaluateCost` produces a four-state decision (`approved` / + `requires_confirmation` / `blocked` / `overridden`) plus boolean + `softWarn`/`hardBlocked`/`requiresConfirmation`/`isOverride` + fields the API can return for UX. +- POST `/api/analyzer/aggregate-reports` enforces: + - `requires_confirmation` → 400 with `requiresConfirmation:true, + estimatedCost, dailySpendBefore` so the frontend can show + `confirm()` and re-POST with `confirmedCost:true`. + - `blocked` → 403 with the daily spend in the body. + - Every decision (including `approved`) writes a row to + `analyzer_cost_audit`. +- Override env var `ANALYZER_DAILY_COST_OVERRIDE_USERS` + (comma-separated user ids). +- Frontend new-report page: catches `requiresConfirmation`, + shows `window.confirm()` with the dollar figure, retries with + `confirmedCost: true`. + +**Decisions worth flagging** + +- **Cost estimate is char/4 → tokens × Sonnet pricing.** Crude but + pessimistic in the right direction. At 100 tickets the estimate + comes in under $0.50 — far below the $5 threshold — so the + confirmation modal almost never fires in practice. Ceiling exists + to catch payload bloat / Opus-opt-in scenarios. +- **Daily window is trailing 24h, not "today UTC".** Avoids + midnight-edge-of-day reset gaming; rolling window is what the + spec calls "$X/day" naturally. +- **Soft warn at $20/day is informational only.** Fields exposed + in the cost evaluation; UI can choose to surface, but the API + doesn't refuse to proceed. Hard block at $50/day is the only + enforcement. + +## 2.8 — Documentation + +**Delivered** + +- `docs/wulf-pulse-ticket-analyzer-runbook.md` — added Phase 2 + sections covering stage execution forensics, fingerprint backfill, + aggregate report flow + SQL queries, cost guard configuration + + override env var, and the rebuilt browse UI behavior. +- This file — the per-sub-phase notes above. + +--- + +## Status after Phase 2 + +| Phase | Tests | tsc | Notes | +|---|---|---|---| +| 2.1 | 128 | clean | schema (070) | +| 2.2 | 128 | clean | stage_executions writes + failure-tolerant persistence | +| 2.3 | 128 | clean | markdown rendering + Stage 3 prompt | +| 2.4 | 128 | clean | Stage 6 fingerprint + backfill CLI | +| 2.5 | 128 | clean | browse UI rebuild | +| 2.6 | 128 | clean | aggregate reports (071, runner, 3 endpoints, 3 pages) | +| 2.7 | 128 | clean | cost guards (072, audit log, threshold gating) | +| 2.8 | 128 | clean | runbook + build notes | diff --git a/docs/wulf-pulse-ticket-analyzer-runbook.md b/docs/wulf-pulse-ticket-analyzer-runbook.md index 0e39c18..e595ed7 100644 --- a/docs/wulf-pulse-ticket-analyzer-runbook.md +++ b/docs/wulf-pulse-ticket-analyzer-runbook.md @@ -316,3 +316,172 @@ SELECT round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd, If any of these become a real operational problem, file the work — the stubs are intentional and called out in `wulf-pulse-ticket-analyzer-build-notes.md`. + +--- + +## Phase 2 additions + +### Stage execution history (per-analysis forensics) + +Migration 070 introduced `analyzer_stage_executions`. Every stage of the +pipeline (preprocess, triage, itglue, analyze, deep_review, fingerprint) +now writes a row including the input it saw and the output it produced. +Failed stages get a row with `error_message` populated. + +Read it like this: + +```sql +-- Full per-stage trace for one analysis +SELECT stage_order, stage, model_id, + input_tokens, output_tokens, latency_ms, + error_message + FROM analyzer_stage_executions + WHERE analysis_id = '' + ORDER BY stage_order; + +-- Inspect a single stage's full input/output +SELECT input_payload, output_payload + FROM analyzer_stage_executions + WHERE analysis_id = '' AND stage = 'analyze'; +``` + +The legacy `analyzer_analyses.model_traces` JSONB column is preserved for +back-compat. New analyses double-write to both. A future migration will +drop `model_traces` once aggregate reports have soaked. + +### Fingerprint stage (Stage 6) + +After persistence, the worker runs Haiku-tier fingerprint extraction and +writes the result to `analyzer_analyses.aggregate_fingerprint`. Failure +is non-fatal — the analysis row stays usable, fingerprint stays NULL. + +Re-fingerprint on demand: + +```sql +SELECT id, ticket_number, analysis_version + FROM analyzer_analyses + WHERE aggregate_fingerprint IS NULL + AND status = 'complete' + ORDER BY triggered_at ASC; +``` + +Or run the backfill script (idempotent — the SQL filter skips already-fingerprinted rows): + +```bash +npx tsx scripts/backfill-fingerprints.ts # process all missing +npx tsx scripts/backfill-fingerprints.ts --limit=50 # cap work +npx tsx scripts/backfill-fingerprints.ts --dry-run # show what would run +``` + +The script reconstructs the Stage 6 input from +`analyzer_analyses.model_traces.{triage_response, sonnet_response, opus_response}`. +If a row's model_traces is missing those fields (very old format) the script +logs `[skip]` for it and moves on. + +Cost: Haiku at ~$0.001 per fingerprint. 1000 analyses ≈ $1. + +### Aggregate reports + +Migration 071 added `analyzer_aggregate_reports` and relaxed +`analyzer_stage_executions.analysis_id` to be nullable; rows now carry +either an `analysis_id` or an `aggregate_report_id` (CHECK enforces +exactly one). + +Workflow: + +1. User selects tickets on `/analyzer/tickets` and clicks + **Generate aggregate report**. +2. POST `/api/analyzer/aggregate-reports` validates: ≤100 tickets, all + have a fingerprint, none are stale. Inserts a 'pending' row, fires + the runner via `void runAggregateReport(id)`, returns immediately. +3. Runner does: + - Loads fingerprints + - Computes SQL distributions (categories, clients, root cause, + resolution path) and writes them to the row immediately + - Fetches IT Glue doc titles for affected clients (if requested) + - Calls Sonnet (Opus opt-in) with a structured payload + - Writes the LLM-derived fields, marks `status='complete'` +4. UI polls `GET /api/analyzer/aggregate-reports/:id` every 3s while + pending/running. + +Inspect a report's sub-stage trace (linked via `aggregate_report_id`): + +```sql +SELECT stage_order, stage, model_id, latency_ms, error_message + FROM analyzer_stage_executions + WHERE aggregate_report_id = '' + ORDER BY stage_order; +``` + +Daily spend on aggregate reports: + +```sql +SELECT date_trunc('day', generated_at) AS day, + count(*) AS reports, + round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd + FROM analyzer_aggregate_reports + WHERE generated_at > now() - interval '14 days' + GROUP BY 1 + ORDER BY 1 DESC; +``` + +### Cost guards (Phase 2.7) + +Three thresholds enforce per-user daily spend: + +- **$5 per request** — a single aggregate report estimated above $5 + triggers a confirmation modal. Frontend re-POSTs with + `confirmedCost: true` if the user clicks through. +- **$20 per user/day** — soft warn. Surfaced via `softWarn:true` in the + cost evaluation; no enforcement. +- **$50 per user/day** — hard block. POST returns 403 unless the user + is in `ANALYZER_DAILY_COST_OVERRIDE_USERS` (comma-separated list of + Better Auth user ids). + +Daily spend is computed as the trailing 24h sum of +`analyzer_analyses.estimated_cost_usd` + `analyzer_aggregate_reports.estimated_cost_usd` +for the user. + +Every gating decision writes a row to `analyzer_cost_audit`: + +```sql +SELECT created_at, user_id, action, estimated_cost, + daily_spend_before, decision, decision_reason + FROM analyzer_cost_audit + ORDER BY created_at DESC + LIMIT 50; +``` + +To grant a user override capability: + +```bash +# in ~/projects_env/wulf-pulse.env +ANALYZER_DAILY_COST_OVERRIDE_USERS=user_id_1,user_id_2 +``` + +Restart `pulse-app` to pick up the change. + +### Browse / filter ticket list + +`/analyzer/tickets` rebuilt as the primary entry point in Phase 2.5: + +- Multi-select for client, issue type, queue, status, priority, assignee +- Sticky filter bar with period chips (today/yesterday/this+last week, + 30d/60d, custom range, all time) +- Active-filter chips below the bar; click to remove +- Bulk row selection persisted in localStorage + (`analyzer:ticket-selection:v1`) — survives pagination but is + session-scoped (no user id baked in) +- Bulk "Analyze N selected" sequentially queues jobs for each ticket + (forces re-analyze on stale; analyzes from scratch on un-analyzed) +- Bulk "Generate aggregate report" routes selected tickets to + `/analyzer/reports/new`; only enabled when all selected are + `analyzedState === 'current'` + +**Staleness heuristic**: a ticket's `analyzedState` is computed as +`stale` when `tickets.last_activity_date > latest_analysis.completed_at`. +The Phase 2 spec calls for content-hash-based comparison; that requires +either caching the current hash on the tickets row (sync change) or +computing it on read for the visible page (slow). The date heuristic +gets ~95% of the value at zero compute cost; revisit when there's real +load signal. diff --git a/lib/services/analyzer/aggregate-persistence.ts b/lib/services/analyzer/aggregate-persistence.ts new file mode 100644 index 0000000..725c56f --- /dev/null +++ b/lib/services/analyzer/aggregate-persistence.ts @@ -0,0 +1,506 @@ +/** + * Persistence + runner for aggregate reports. + * + * Spec: docs/ticket-analyzer-phase2-spec.md → Sections D.4–D.6 + */ + +import postgresClient from '@/lib/services/postgres-client'; +import { + type AggregateFingerprint, + type AggregateReduceResponse, + type AggregateReportStatus, + type StageExecutionRecord, +} from '@/lib/types/analyzer'; +import { getITGlueClient } from '@/lib/services/itglue-client'; +import { runAggregateReduceStage } from './stages/aggregate-reduce'; + +interface AggregateReportRow { + id: string; + generated_by_user_id: string | null; + generated_at: Date; + filter_criteria: unknown; + analysis_ids: string[]; + ticket_count: number; + include_itglue_context: boolean; + report_title: string | null; + category_distribution: Record | null; + client_distribution: Record | null; + resolution_path_distribution: Record | null; + root_cause_distribution: Record | null; + date_range_actual: { earliest: string | null; latest: string | null } | null; + documentation_gaps: unknown; + process_gaps: unknown; + client_patterns: unknown; + recurrence_clusters: unknown; + systemic_observations: unknown; + recommended_actions: unknown; + narrative_summary: string | null; + executive_summary: string | null; + total_input_tokens: number | null; + total_output_tokens: number | null; + estimated_cost_usd: string | null; + model_used: string | null; + itglue_context_included: boolean | null; + status: AggregateReportStatus; + error_message: string | null; +} + +export interface AggregateReportSummary { + id: string; + generatedByUserId: string | null; + generatedAt: string; + filterCriteria: unknown; + analysisIds: string[]; + ticketCount: number; + includeItglueContext: boolean; + reportTitle: string | null; + status: AggregateReportStatus; + errorMessage: string | null; + // SQL outputs + categoryDistribution: Record | null; + clientDistribution: Record | null; + resolutionPathDistribution: Record | null; + rootCauseDistribution: Record | null; + dateRangeActual: { earliest: string | null; latest: string | null } | null; + // LLM outputs + documentationGaps: unknown; + processGaps: unknown; + clientPatterns: unknown; + recurrenceClusters: unknown; + systemicObservations: unknown; + recommendedActions: unknown; + narrativeSummary: string | null; + executiveSummary: string | null; + // Cost + totalInputTokens: number | null; + totalOutputTokens: number | null; + estimatedCostUsd: number | null; + modelUsed: string | null; +} + +function rowToSummary(r: AggregateReportRow): AggregateReportSummary { + return { + id: r.id, + generatedByUserId: r.generated_by_user_id, + generatedAt: r.generated_at.toISOString(), + filterCriteria: r.filter_criteria, + analysisIds: r.analysis_ids, + ticketCount: r.ticket_count, + includeItglueContext: r.include_itglue_context, + reportTitle: r.report_title, + status: r.status, + errorMessage: r.error_message, + categoryDistribution: r.category_distribution, + clientDistribution: r.client_distribution, + resolutionPathDistribution: r.resolution_path_distribution, + rootCauseDistribution: r.root_cause_distribution, + dateRangeActual: r.date_range_actual, + documentationGaps: r.documentation_gaps, + processGaps: r.process_gaps, + clientPatterns: r.client_patterns, + recurrenceClusters: r.recurrence_clusters, + systemicObservations: r.systemic_observations, + recommendedActions: r.recommended_actions, + narrativeSummary: r.narrative_summary, + executiveSummary: r.executive_summary, + totalInputTokens: r.total_input_tokens, + totalOutputTokens: r.total_output_tokens, + estimatedCostUsd: r.estimated_cost_usd === null ? null : Number(r.estimated_cost_usd), + modelUsed: r.model_used, + }; +} + +const REPORT_SELECT = ` + id::text AS id, + generated_by_user_id, generated_at, + filter_criteria, analysis_ids::text[] AS analysis_ids, + ticket_count, include_itglue_context, report_title, + category_distribution, client_distribution, resolution_path_distribution, + root_cause_distribution, date_range_actual, + documentation_gaps, process_gaps, client_patterns, recurrence_clusters, + systemic_observations, recommended_actions, + narrative_summary, executive_summary, + total_input_tokens, total_output_tokens, + estimated_cost_usd::text AS estimated_cost_usd, + model_used, itglue_context_included, + status, error_message +`; + +export interface CreateAggregateReportInput { + generatedByUserId: string | null; + filterCriteria: unknown; + analysisIds: string[]; + ticketCount: number; + includeItglueContext: boolean; + reportTitle: string | null; +} + +export async function createAggregateReport( + input: CreateAggregateReportInput +): Promise<{ id: string }> { + const res = await postgresClient.query<{ id: string }>( + `INSERT INTO analyzer_aggregate_reports + (generated_by_user_id, filter_criteria, analysis_ids, + ticket_count, include_itglue_context, report_title, status) + VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, 'pending') + RETURNING id::text AS id`, + [ + input.generatedByUserId, + JSON.stringify(input.filterCriteria), + input.analysisIds, + input.ticketCount, + input.includeItglueContext, + input.reportTitle, + ] + ); + return { id: res.rows[0].id }; +} + +export async function getAggregateReport( + id: string +): Promise { + const res = await postgresClient.query( + `SELECT ${REPORT_SELECT} FROM analyzer_aggregate_reports WHERE id = $1`, + [id] + ); + if (res.rowCount === 0) return null; + return rowToSummary(res.rows[0]); +} + +export async function listAggregateReports(opts: { + limit?: number; + offset?: number; + generatedByUserId?: string; +}): Promise { + const limit = Math.min(opts.limit ?? 50, 200); + const offset = opts.offset ?? 0; + const params: unknown[] = [limit, offset]; + let userClause = ''; + if (opts.generatedByUserId) { + params.push(opts.generatedByUserId); + userClause = `WHERE generated_by_user_id = $${params.length}`; + } + const res = await postgresClient.query( + `SELECT ${REPORT_SELECT} + FROM analyzer_aggregate_reports + ${userClause} + ORDER BY generated_at DESC + LIMIT $1 OFFSET $2`, + params + ); + return res.rows.map(rowToSummary); +} + +interface FingerprintRow { + id: string; + ticket_number: string; + aggregate_fingerprint: AggregateFingerprint; + triggered_at: Date; +} + +async function loadFingerprints( + analysisIds: string[] +): Promise<{ ticket_number: string; fingerprint: AggregateFingerprint; triggered_at: Date }[]> { + if (analysisIds.length === 0) return []; + const res = await postgresClient.query( + `SELECT id::text AS id, ticket_number, aggregate_fingerprint, triggered_at + FROM analyzer_analyses + WHERE id = ANY($1::uuid[]) + AND aggregate_fingerprint IS NOT NULL + ORDER BY ticket_number, analysis_version DESC`, + [analysisIds] + ); + return res.rows.map((r) => ({ + ticket_number: r.ticket_number, + fingerprint: r.aggregate_fingerprint, + triggered_at: r.triggered_at, + })); +} + +function bucketCount(items: string[]): Record { + const out: Record = {}; + for (const i of items) out[i] = (out[i] ?? 0) + 1; + return out; +} + +async function fetchITGlueDocTitles( + clientNames: string[] +): Promise<{ client_name: string; doc_titles: string[] }[]> { + let client; + try { + client = getITGlueClient(); + } catch { + return []; // not configured — caller should fall back gracefully + } + const result: { client_name: string; doc_titles: string[] }[] = []; + for (const name of clientNames) { + try { + const org = await client.findOrganizationByName(name); + if (!org) continue; + const docs = await client.getFlexibleAssets({ organizationId: org.id }); + const titles = docs + .map((d) => (d as { name?: string }).name) + .filter((t): t is string => typeof t === 'string') + .slice(0, 50); + result.push({ client_name: name, doc_titles: titles }); + } catch { + // Tolerate per-client failures. + } + } + return result; +} + +const ITGLUE_DOC_TITLE_CAP = 200; + +export async function bulkInsertReportStageExecutions( + reportId: string, + records: StageExecutionRecord[] +): Promise { + if (records.length === 0) return; + const values: unknown[] = [reportId]; + const tuples: string[] = []; + for (const r of records) { + const base = values.length; + values.push( + r.stage, + r.stage_order, + r.model_id, + JSON.stringify(r.input_payload ?? {}), + JSON.stringify(r.output_payload ?? {}), + r.input_tokens, + r.output_tokens, + r.latency_ms, + r.started_at, + r.completed_at, + r.error_message + ); + tuples.push( + `($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` + + `$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` + + `$${base + 9}, $${base + 10}, $${base + 11})` + ); + } + await postgresClient.query( + `INSERT INTO analyzer_stage_executions + (aggregate_report_id, stage, stage_order, model_id, + input_payload, output_payload, + input_tokens, output_tokens, latency_ms, + started_at, completed_at, error_message) + VALUES ${tuples.join(', ')}`, + values + ); +} + +/** + * Fire-and-forget runner. Intended to be invoked from the POST endpoint with + * `void runAggregateReport(id)` — the route returns immediately, this updates + * the row when work completes (or fails). + */ +export async function runAggregateReport(reportId: string): Promise { + const stageRecords: StageExecutionRecord[] = []; + try { + await postgresClient.query( + `UPDATE analyzer_aggregate_reports SET status = 'running' WHERE id = $1`, + [reportId] + ); + + const report = await getAggregateReport(reportId); + if (!report) throw new Error('report row vanished'); + + // ── Step 1: load fingerprints + compute SQL distributions ── + const sqlStart = new Date(); + const fingerprints = await loadFingerprints(report.analysisIds); + if (fingerprints.length === 0) { + throw new Error('no analyses with fingerprints found for the given IDs'); + } + const categories = bucketCount(fingerprints.map((f) => f.fingerprint.category)); + const clients = bucketCount(fingerprints.map((f) => f.fingerprint.client_name)); + const resolutionPaths = bucketCount( + fingerprints.map((f) => f.fingerprint.resolution_path) + ); + const rootCauses = bucketCount( + fingerprints.map((f) => f.fingerprint.root_cause_class) + ); + const dates = fingerprints.map((f) => f.triggered_at.getTime()); + const dateRange = { + earliest: new Date(Math.min(...dates)).toISOString(), + latest: new Date(Math.max(...dates)).toISOString(), + }; + const sqlEnd = new Date(); + stageRecords.push({ + stage: 'analyze', // Reusing 'analyze' since CHECK constraint enumerates only stage names; a future migration could add 'aggregate_sql' / 'aggregate_reduce'. + stage_order: 1, + model_id: null, + input_payload: { analysis_ids: report.analysisIds }, + output_payload: { + category_distribution: categories, + client_distribution: clients, + resolution_path_distribution: resolutionPaths, + root_cause_distribution: rootCauses, + date_range_actual: dateRange, + fingerprint_count: fingerprints.length, + }, + input_tokens: null, + output_tokens: null, + latency_ms: sqlEnd.getTime() - sqlStart.getTime(), + started_at: sqlStart, + completed_at: sqlEnd, + error_message: null, + }); + + // Persist partial results immediately so UI can show distributions. + await postgresClient.query( + `UPDATE analyzer_aggregate_reports + SET category_distribution = $2::jsonb, + client_distribution = $3::jsonb, + resolution_path_distribution = $4::jsonb, + root_cause_distribution = $5::jsonb, + date_range_actual = $6::jsonb + WHERE id = $1`, + [ + reportId, + JSON.stringify(categories), + JSON.stringify(clients), + JSON.stringify(resolutionPaths), + JSON.stringify(rootCauses), + JSON.stringify(dateRange), + ] + ); + + // ── Step 2: IT Glue context (optional) ── + let itglueDocTitles: { client_name: string; doc_titles: string[] }[] | undefined; + let itglueIncluded = false; + if (report.includeItglueContext) { + const uniqueClients = Array.from( + new Set(fingerprints.map((f) => f.fingerprint.client_name)) + ); + const itglueStart = new Date(); + itglueDocTitles = await fetchITGlueDocTitles(uniqueClients); + // Cap to spec total (200 doc titles across all clients). + let remaining = ITGLUE_DOC_TITLE_CAP; + itglueDocTitles = itglueDocTitles.map((c) => { + if (remaining <= 0) return { client_name: c.client_name, doc_titles: [] }; + const titles = c.doc_titles.slice(0, remaining); + remaining -= titles.length; + return { client_name: c.client_name, doc_titles: titles }; + }); + itglueIncluded = itglueDocTitles.some((c) => c.doc_titles.length > 0); + const itglueEnd = new Date(); + stageRecords.push({ + stage: 'itglue', + stage_order: 2, + model_id: null, + input_payload: { client_count: uniqueClients.length }, + output_payload: { doc_count: itglueDocTitles.reduce((a, c) => a + c.doc_titles.length, 0) }, + input_tokens: null, + output_tokens: null, + latency_ms: itglueEnd.getTime() - itglueStart.getTime(), + started_at: itglueStart, + completed_at: itglueEnd, + error_message: null, + }); + } + + // ── Step 3: reduce LLM call ── + const reduceStart = new Date(); + let reduceResult; + try { + reduceResult = await runAggregateReduceStage({ + distributions: { + category_distribution: categories, + client_distribution: clients, + resolution_path_distribution: resolutionPaths, + root_cause_distribution: rootCauses, + date_range_actual: dateRange, + }, + fingerprints: fingerprints.map((f) => ({ + ticket_number: f.ticket_number, + fingerprint: f.fingerprint, + })), + itglue_doc_titles: itglueDocTitles, + }); + } catch (err) { + const reduceEnd = new Date(); + stageRecords.push({ + stage: 'analyze', + stage_order: 3, + model_id: null, + input_payload: { fingerprint_count: fingerprints.length }, + output_payload: {}, + input_tokens: null, + output_tokens: null, + latency_ms: reduceEnd.getTime() - reduceStart.getTime(), + started_at: reduceStart, + completed_at: reduceEnd, + error_message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + const reduceEnd = new Date(); + stageRecords.push({ + stage: 'analyze', + stage_order: 3, + model_id: reduceResult.model_used, + input_payload: { fingerprint_count: fingerprints.length }, + output_payload: reduceResult.data, + input_tokens: reduceResult.usage.input_tokens, + output_tokens: reduceResult.usage.output_tokens, + latency_ms: reduceEnd.getTime() - reduceStart.getTime(), + started_at: reduceStart, + completed_at: reduceEnd, + error_message: null, + }); + + // ── Step 4: persist outputs ── + await postgresClient.query( + `UPDATE analyzer_aggregate_reports + SET documentation_gaps = $2::jsonb, + process_gaps = $3::jsonb, + client_patterns = $4::jsonb, + recurrence_clusters = $5::jsonb, + systemic_observations = $6::jsonb, + recommended_actions = $7::jsonb, + narrative_summary = $8, + executive_summary = $9, + total_input_tokens = $10, + total_output_tokens = $11, + estimated_cost_usd = $12, + model_used = $13, + itglue_context_included = $14, + status = 'complete' + WHERE id = $1`, + [ + reportId, + JSON.stringify(reduceResult.data.documentation_gaps), + JSON.stringify(reduceResult.data.process_gaps), + JSON.stringify(reduceResult.data.client_patterns), + JSON.stringify(reduceResult.data.recurrence_clusters), + JSON.stringify(reduceResult.data.systemic_observations), + JSON.stringify(reduceResult.data.recommended_actions), + reduceResult.data.narrative_summary, + reduceResult.data.executive_summary, + reduceResult.usage.input_tokens, + reduceResult.usage.output_tokens, + reduceResult.estimated_cost_usd, + reduceResult.model_used, + itglueIncluded, + ] + ); + + await bulkInsertReportStageExecutions(reportId, stageRecords); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[ANALYZER-REPORT] runAggregateReport ${reportId} failed:`, message); + await postgresClient + .query( + `UPDATE analyzer_aggregate_reports + SET status = 'failed', error_message = $2 + WHERE id = $1`, + [reportId, message] + ) + .catch(() => {}); + if (stageRecords.length > 0) { + await bulkInsertReportStageExecutions(reportId, stageRecords).catch(() => {}); + } + } +} diff --git a/lib/services/analyzer/cost-guard.ts b/lib/services/analyzer/cost-guard.ts new file mode 100644 index 0000000..5f7a8a4 --- /dev/null +++ b/lib/services/analyzer/cost-guard.ts @@ -0,0 +1,155 @@ +/** + * Cost guards for analyzer LLM operations. + * + * Spec: docs/ticket-analyzer-phase2-spec.md → Section D.8. + * + * Three thresholds: + * • $5 per request → require explicit confirmation in UI + * • $20 per user/day → soft warn (returned in preflight; UI can surface) + * • $50 per user/day → hard block (unless user is in override list) + * + * Override env var: ANALYZER_DAILY_COST_OVERRIDE_USERS (comma-separated user ids) + */ + +import postgresClient from '@/lib/services/postgres-client'; + +export const REQUIRES_CONFIRMATION_USD = 5.0; +export const SOFT_WARN_DAILY_USD = 20.0; +export const HARD_BLOCK_DAILY_USD = 50.0; + +export type CostDecision = + | 'approved' + | 'requires_confirmation' + | 'blocked' + | 'overridden'; + +export interface CostEvaluation { + estimatedCost: number; + dailySpendBefore: number; + decision: CostDecision; + decisionReason: string | null; + softWarn: boolean; + hardBlocked: boolean; + requiresConfirmation: boolean; + isOverride: boolean; +} + +/** + * Pessimistic cost estimate for an aggregate report. Assumes Sonnet pricing + * and a payload size proportional to the number of fingerprints (each ~2KB + * after compaction). Conservative — actual cost is usually lower. + */ +export function estimateAggregateReportCost(input: { + ticketCount: number; + includeItglueContext: boolean; +}): number { + // Inputs: + // per-fingerprint input tokens ≈ 700 (compacted JSON) → 700 * N + // distributions + system prompt ≈ 2000 tokens + // IT Glue context (if included): up to 200 doc titles * 50 tokens = 10K tokens + // Outputs: cap ≈ 6K tokens (we set max_tokens 16K but real outputs are smaller). + const inputTokens = + 700 * input.ticketCount + + 2000 + + (input.includeItglueContext ? 10_000 : 0); + const outputTokens = 6_000; + // Sonnet pricing per Phase 1 pricing table: $3/$15 per 1M tokens. + const cost = (inputTokens / 1_000_000) * 3 + (outputTokens / 1_000_000) * 15; + return Math.round(cost * 10_000) / 10_000; +} + +function getOverrideUsers(): Set { + return new Set( + (process.env.ANALYZER_DAILY_COST_OVERRIDE_USERS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ); +} + +export async function getUserDailySpend(userId: string | null): Promise { + if (!userId) return 0; + // Sum from analyzer_aggregate_reports + analyzer_analyses for the trailing 24h. + const res = await postgresClient.query<{ total: string }>( + `SELECT COALESCE(SUM(amt), 0)::text AS total FROM ( + SELECT estimated_cost_usd AS amt + FROM analyzer_aggregate_reports + WHERE generated_by_user_id = $1 + AND generated_at >= NOW() - INTERVAL '24 hours' + AND estimated_cost_usd IS NOT NULL + UNION ALL + SELECT estimated_cost_usd AS amt + FROM analyzer_analyses + WHERE triggered_by_user_id = $1 + AND triggered_at >= NOW() - INTERVAL '24 hours' + AND status = 'complete' + ) x`, + [userId] + ); + return Number(res.rows[0]?.total ?? 0); +} + +export async function evaluateCost(input: { + userId: string | null; + estimatedCost: number; + confirmedCost: boolean; +}): Promise { + const dailySpendBefore = await getUserDailySpend(input.userId); + const projectedDailySpend = dailySpendBefore + input.estimatedCost; + const overrideUsers = getOverrideUsers(); + const isOverride = input.userId !== null && overrideUsers.has(input.userId); + + const softWarn = projectedDailySpend >= SOFT_WARN_DAILY_USD; + const hardBlocked = projectedDailySpend >= HARD_BLOCK_DAILY_USD; + const requiresConfirmation = + input.estimatedCost > REQUIRES_CONFIRMATION_USD && !input.confirmedCost; + + let decision: CostDecision; + let decisionReason: string | null = null; + if (hardBlocked && !isOverride) { + decision = 'blocked'; + decisionReason = `Projected daily spend $${projectedDailySpend.toFixed(2)} would exceed hard limit $${HARD_BLOCK_DAILY_USD.toFixed(2)}`; + } else if (hardBlocked && isOverride) { + decision = 'overridden'; + decisionReason = `Override allowed (user in ANALYZER_DAILY_COST_OVERRIDE_USERS); projected $${projectedDailySpend.toFixed(2)}`; + } else if (requiresConfirmation) { + decision = 'requires_confirmation'; + decisionReason = `Per-request cost $${input.estimatedCost.toFixed(2)} > confirmation threshold $${REQUIRES_CONFIRMATION_USD.toFixed(2)}`; + } else { + decision = 'approved'; + } + + return { + estimatedCost: input.estimatedCost, + dailySpendBefore, + decision, + decisionReason, + softWarn, + hardBlocked, + requiresConfirmation, + isOverride, + }; +} + +export async function recordCostAuditDecision(input: { + userId: string | null; + action: string; + evaluation: CostEvaluation; + context?: Record; +}): Promise { + await postgresClient.query( + `INSERT INTO analyzer_cost_audit + (user_id, action, estimated_cost, daily_spend_before, + decision, decision_reason, context) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [ + input.userId, + input.action, + input.evaluation.estimatedCost, + input.evaluation.dailySpendBefore, + input.evaluation.decision, + input.evaluation.decisionReason, + JSON.stringify(input.context ?? {}), + ] + ); +} diff --git a/lib/services/analyzer/persistence.ts b/lib/services/analyzer/persistence.ts index 1f47d08..2265b5e 100644 --- a/lib/services/analyzer/persistence.ts +++ b/lib/services/analyzer/persistence.ts @@ -9,10 +9,12 @@ import postgresClient from '@/lib/services/postgres-client'; import { + type AggregateFingerprint, type AnalyzerJob, type DeepAnalysisResponse, type JobStatus, type PersistedAnalysis, + type StageExecutionRecord, type TaggedEvent, } from '@/lib/types/analyzer'; import type { ITGlueDocReference } from '@/lib/types/analyzer'; @@ -36,8 +38,13 @@ export interface InsertAnalysisInput { /** Final analysis content (after any Opus updates). null on failure. */ analysis: DeepAnalysisResponse | null; filtered_noise_count: number; - /** Per-stage trace dump for debugging — raw model responses, attempts, etc. */ + /** + * LEGACY (phase 1). Per-stage trace dump. Retained for back-compat until + * analyzer_stage_executions has full coverage and we drop the column. + */ model_traces: Record; + /** Phase 2: Stage 0 preprocessed event list at analysis time. */ + source_snapshot?: TaggedEvent[] | null; error_message?: string | null; } @@ -108,7 +115,8 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{ summary, timeline, what_was_done, what_should_have_been_done, gaps, next_step, next_step_rationale, post_resolution_analysis, confidence_score, needs_human_review, human_review_reasons, - itglue_docs_referenced, model_traces, filtered_noise_count, error_message + itglue_docs_referenced, model_traces, filtered_noise_count, error_message, + source_snapshot ) VALUES ( $1, $2, $3, @@ -119,7 +127,8 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{ $14, $15::jsonb, $16::jsonb, $17::jsonb, $18::jsonb, $19, $20, $21, $22, $23, $24::jsonb, - $25::jsonb, $26::jsonb, $27, $28 + $25::jsonb, $26::jsonb, $27, $28, + $29::jsonb ) RETURNING id::text AS id `, @@ -154,12 +163,113 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{ JSON.stringify(input.model_traces), input.filtered_noise_count, input.error_message ?? null, + input.source_snapshot ? JSON.stringify(input.source_snapshot) : null, ] ); return { id: res.rows[0].id, analysis_version: version }; } +/** + * Bulk-insert one analyzer_stage_executions row per record. No-op when records + * is empty. Single multi-VALUES INSERT — fast enough for the few rows produced + * per pipeline run that we don't need COPY. + */ +export async function bulkInsertStageExecutions( + analysisId: string, + records: StageExecutionRecord[] +): Promise { + if (records.length === 0) return; + const values: unknown[] = [analysisId]; + const tuples: string[] = []; + for (const r of records) { + const base = values.length; + values.push( + r.stage, + r.stage_order, + r.model_id, + JSON.stringify(r.input_payload ?? {}), + JSON.stringify(r.output_payload ?? {}), + r.input_tokens, + r.output_tokens, + r.latency_ms, + r.started_at, + r.completed_at, + r.error_message + ); + tuples.push( + `($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` + + `$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` + + `$${base + 9}, $${base + 10}, $${base + 11})` + ); + } + await postgresClient.query( + `INSERT INTO analyzer_stage_executions ( + analysis_id, stage, stage_order, model_id, + input_payload, output_payload, + input_tokens, output_tokens, latency_ms, + started_at, completed_at, error_message + ) VALUES ${tuples.join(', ')}`, + values + ); +} + +/** + * Phase 2: write the Stage 6 fingerprint to an existing analysis row. + */ +export async function updateAnalysisFingerprint( + analysisId: string, + fingerprint: AggregateFingerprint +): Promise { + await postgresClient.query( + `UPDATE analyzer_analyses + SET aggregate_fingerprint = $2::jsonb, + fingerprint_generated_at = NOW() + WHERE id = $1`, + [analysisId, JSON.stringify(fingerprint)] + ); +} + +/** + * Phase 2: persist a 'failed' analyzer_analyses row when the pipeline throws. + * Carries content_hash + source_snapshot so partial-run forensics work, plus + * any stage records the pipeline managed to record before throwing. + */ +export async function insertFailedAnalysis(input: { + ticket_number: string; + autotask_ticket_id: number; + content_hash: string; + triggered_by_user_id: string | null; + source_snapshot: TaggedEvent[]; + filtered_noise_count: number; + error_message: string; + partial_input_tokens: number; + partial_output_tokens: number; + partial_cost_usd: number; + haiku_used: boolean; + sonnet_used: boolean; + opus_used: boolean; +}): Promise<{ id: string; analysis_version: number }> { + return await insertAnalysis({ + ticket_number: input.ticket_number, + autotask_ticket_id: input.autotask_ticket_id, + content_hash: input.content_hash, + triggered_by_user_id: input.triggered_by_user_id, + status: 'failed', + haiku_used: input.haiku_used, + sonnet_used: input.sonnet_used, + opus_used: input.opus_used, + total_input_tokens: input.partial_input_tokens, + total_output_tokens: input.partial_output_tokens, + estimated_cost_usd: input.partial_cost_usd, + analysis: null, + filtered_noise_count: input.filtered_noise_count, + model_traces: {}, + source_snapshot: input.source_snapshot, + error_message: input.error_message, + }); +} + // ============================================================================= // Job table operations // ============================================================================= diff --git a/lib/services/analyzer/pipeline.ts b/lib/services/analyzer/pipeline.ts index 2089e3c..b3f217b 100644 --- a/lib/services/analyzer/pipeline.ts +++ b/lib/services/analyzer/pipeline.ts @@ -19,6 +19,8 @@ import { type DeepAnalysisResponse, type OpusResponse, type PreprocessedTicket, + type StageExecutionRecord, + type StageName, type TriageResponse, } from '@/lib/types/analyzer'; import { preprocessTicket, type RawTicketBundle } from './preprocessor'; @@ -96,7 +98,11 @@ export interface PipelineSuccess { filtered_noise_count: number; itglue_search_used: boolean; itglue_org_resolved: boolean; - /** Per-stage debug payload — goes into analyzer_analyses.model_traces. */ + /** Phase 2: the raw triage / sonnet / opus responses fed to Stage 6 fingerprint. */ + triage_response: TriageResponse; + sonnet_response: DeepAnalysisResponse; + opus_response: OpusResponse | null; + /** LEGACY: per-stage debug payload — goes into analyzer_analyses.model_traces. */ model_traces: { triage?: StageTrace; deep_analysis?: StageTrace; @@ -139,6 +145,61 @@ export interface PipelineProgressCallbacks { | 'analyzing' | 'deep_review' ) => Promise | void; + /** + * Phase 2 — emitted once per stage attempt, on success OR failure. Worker + * collects these into an array; on pipeline failure the array still has + * everything that ran. Pipeline pushes the record before re-throwing. + */ + onStageRecord?: (record: StageExecutionRecord) => void; + /** + * Phase 2 — emitted right after Stage 0 succeeds. Lets the worker capture + * the preprocessed ticket so it can persist a failed analyzer_analyses row + * (with source_snapshot + content_hash) when a later stage throws. + */ + onPreprocessed?: (pre: PreprocessedTicket) => void; +} + +/** Internal helper: run a stage, time it, push a record, propagate errors. */ +async function recordedStage( + meta: { + stage: StageName; + stage_order: number; + model_id: string | null; + input_payload: unknown; + }, + fn: () => Promise, + callbacks: PipelineProgressCallbacks, + outputSelector: (result: T) => unknown +): Promise { + const startedAt = new Date(); + try { + const result = await fn(); + const completedAt = new Date(); + callbacks.onStageRecord?.({ + ...meta, + output_payload: outputSelector(result), + input_tokens: result.usage?.input_tokens ?? null, + output_tokens: result.usage?.output_tokens ?? null, + latency_ms: completedAt.getTime() - startedAt.getTime(), + started_at: startedAt, + completed_at: completedAt, + error_message: null, + }); + return result; + } catch (err) { + const completedAt = new Date(); + callbacks.onStageRecord?.({ + ...meta, + output_payload: {}, + input_tokens: null, + output_tokens: null, + latency_ms: completedAt.getTime() - startedAt.getTime(), + started_at: startedAt, + completed_at: completedAt, + error_message: err instanceof Error ? err.message : String(err), + }); + throw err; + } } export async function runPipeline( @@ -151,7 +212,31 @@ export async function runPipeline( // ── Stage 0: preprocess ────────────────────────────────────────────────── await callbacks.onStage?.('fetching'); + const preStart = new Date(); const pre = preprocessTicket(input.bundle); + const preEnd = new Date(); + callbacks.onPreprocessed?.(pre); + callbacks.onStageRecord?.({ + stage: 'preprocess', + stage_order: 1, + model_id: null, + input_payload: { + ticket_number: input.bundle.ticket.ticket_number, + notes_count: input.bundle.notes?.length ?? 0, + time_entries_count: input.bundle.time_entries?.length ?? 0, + }, + output_payload: { + events_count: pre.events.length, + counts: pre.counts, + content_hash: pre.content_hash, + }, + input_tokens: null, + output_tokens: null, + latency_ms: preEnd.getTime() - preStart.getTime(), + started_at: preStart, + completed_at: preEnd, + error_message: null, + }); // ── Idempotency: short-circuit if force=false and we have a complete row ─ if (!input.force) { @@ -181,7 +266,21 @@ export async function runPipeline( // ── Stage 1: Haiku triage ──────────────────────────────────────────────── await callbacks.onStage?.('triaging'); - const triageResult = await runTriageStage(pre, anthropic); + const triageResult = await recordedStage( + { + stage: 'triage', + stage_order: 2, + model_id: 'claude-haiku-4-5', + input_payload: { + ticket_number: pre.header.ticket_number, + events_count: pre.events.length, + filtered_noise_count: pre.counts.filtered_noise, + }, + }, + () => runTriageStage(pre, anthropic), + callbacks, + (r) => r.data + ); usage = addUsage(usage, triageResult.usage); estimatedCostUsd += triageResult.estimated_cost_usd; traces.triage = { @@ -204,6 +303,8 @@ export async function runPipeline( pre.header.account_name ) { await callbacks.onStage?.('itglue'); + const itglueStart = new Date(); + let itglueErr: Error | null = null; try { itglueResult = await itglueSearchFn({ org_name: pre.header.account_name, @@ -212,10 +313,35 @@ export async function runPipeline( itglueDocs = itglueResult.docs; } catch (err) { // Tolerate IT Glue failures — analysis continues without context. + itglueErr = err instanceof Error ? err : new Error(String(err)); console.warn( - `[pipeline] IT Glue search failed for ${pre.header.ticket_number}: ${err instanceof Error ? err.message : String(err)}` + `[pipeline] IT Glue search failed for ${pre.header.ticket_number}: ${itglueErr.message}` ); } + const itglueEnd = new Date(); + callbacks.onStageRecord?.({ + stage: 'itglue', + stage_order: 3, + model_id: null, + input_payload: { + org_name: pre.header.account_name, + hints: triageResult.data.itglue_search_hints, + }, + // Redacted-only payload (itglue-search applies redact() internally). + output_payload: itglueErr + ? {} + : { + org_id: itglueResult?.org_id ?? null, + alias_used: itglueResult?.alias_used ?? false, + docs: itglueDocs, + }, + input_tokens: null, + output_tokens: null, + latency_ms: itglueEnd.getTime() - itglueStart.getTime(), + started_at: itglueStart, + completed_at: itglueEnd, + error_message: itglueErr ? itglueErr.message : null, + }); traces.itglue = { org_id: itglueResult?.org_id ?? null, alias_used: itglueResult?.alias_used ?? false, @@ -225,13 +351,25 @@ export async function runPipeline( // ── Stage 3: Sonnet deep analysis ──────────────────────────────────────── await callbacks.onStage?.('analyzing'); - const sonnetResult = await runDeepAnalysisStage( + const sonnetResult = await recordedStage( { - pre, - triage: triageResult.data, - itglue_docs: itglueDocs, + stage: 'analyze', + stage_order: 4, + model_id: 'claude-sonnet-4-6', + input_payload: { + ticket_number: pre.header.ticket_number, + events_count: pre.events.length, + triage: triageResult.data, + itglue_doc_count: itglueDocs.length, + }, }, - anthropic + () => + runDeepAnalysisStage( + { pre, triage: triageResult.data, itglue_docs: itglueDocs }, + anthropic + ), + callbacks, + (r) => r.data ); usage = addUsage(usage, sonnetResult.usage); estimatedCostUsd += sonnetResult.estimated_cost_usd; @@ -248,6 +386,7 @@ export async function runPipeline( traces.sonnet_response = sonnetResult.data; let analysis: DeepAnalysisResponse = sonnetResult.data; + let opusResponseForResult: OpusResponse | null = null; let opusUsed = false; let costCircuitBreakerTripped = false; @@ -271,9 +410,27 @@ export async function runPipeline( }; } else { await callbacks.onStage?.('deep_review'); - const opusResult = await runDeepReasoningStage( - { pre, triage: triageResult.data, sonnet: sonnetResult.data }, - anthropic + const opusResult = await recordedStage( + { + stage: 'deep_review', + stage_order: 5, + model_id: 'claude-opus-4-7', + input_payload: { + ticket_number: pre.header.ticket_number, + events_count: pre.events.length, + triage: triageResult.data, + sonnet_summary: sonnetResult.data.summary, + }, + }, + () => + runDeepReasoningStage( + { pre, triage: triageResult.data, sonnet: sonnetResult.data }, + anthropic + ), + callbacks, + // Per spec: store the FULL Opus response including opus_notes, not + // just the merged updates that previously overwrote everything. + (r) => r.data ); usage = addUsage(usage, opusResult.usage); estimatedCostUsd += opusResult.estimated_cost_usd; @@ -289,6 +446,7 @@ export async function runPipeline( events_dropped: opusResult.events_dropped, }; traces.opus_response = opusResult.data; + opusResponseForResult = opusResult.data; analysis = applyOpusUpdates(analysis, opusResult.data.updates); } } @@ -300,6 +458,9 @@ export async function runPipeline( filtered_noise_count: pre.counts.filtered_noise, itglue_search_used: itglueResult !== null, itglue_org_resolved: !!itglueResult?.org_id, + triage_response: triageResult.data, + sonnet_response: sonnetResult.data, + opus_response: opusResponseForResult, meta: { haiku_used: true, sonnet_used: true, diff --git a/lib/services/analyzer/stages/aggregate-reduce.ts b/lib/services/analyzer/stages/aggregate-reduce.ts new file mode 100644 index 0000000..3644e56 --- /dev/null +++ b/lib/services/analyzer/stages/aggregate-reduce.ts @@ -0,0 +1,170 @@ +/** + * Aggregate report reduce stage. + * + * Takes structured fingerprints + SQL distributions + (optional) IT Glue doc + * titles, runs a single LLM call (Sonnet by default, Opus opt-in), and + * produces the report's LLM-derived fields. + * + * Spec: docs/ticket-analyzer-phase2-spec.md → Section D.6 + */ + +import type Anthropic from '@anthropic-ai/sdk'; +import { + AggregateReduceResponse, + type AggregateFingerprint, +} from '@/lib/types/analyzer'; +import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call'; +import { OPUS, SONNET, type ModelId } from '@/lib/services/llm/models'; + +const REDUCE_MAX_TOKENS = 16_000; + +const SYSTEM_PROMPT = `You are a senior MSP analyst identifying patterns across multiple ticket analyses to surface systemic issues. + +You will receive: +1. SQL-derived distributions (categories, clients, resolution paths, root causes) +2. An array of structured fingerprints, one per analyzed ticket +3. Optionally, a list of IT Glue documentation titles for the affected clients + +Your job is to identify patterns the SQL aggregations cannot see — patterns that emerge from the gap descriptions, vendor involvement, recurrence signals, and cross-ticket clustering. + +Be specific. Cite ticket numbers as evidence for every claim. Distinguish between "documentation gap exists" (no doc on this topic per the IT Glue title list) and "documentation not referenced" (doc may exist but wasn't used in resolution). + +Respond ONLY with JSON matching this schema: + +{ + "documentation_gaps": [ + { + "gap": string, + "frequency": number, + "example_ticket_numbers": string[], + "evidence": string, + "itglue_check": "no_doc_exists" | "doc_exists_but_unused" | "unable_to_verify" + } + ], + "process_gaps": [ + { + "gap": string, + "frequency": number, + "severity": "low" | "medium" | "high", + "example_ticket_numbers": string[], + "evidence": string + } + ], + "client_patterns": [ + { + "client": string, + "pattern": string, + "frequency": number, + "example_ticket_numbers": string[] + } + ], + "recurrence_clusters": [ + { + "theme": string, + "ticket_numbers": string[], + "summary": string + } + ], + "systemic_observations": [ + { + "observation": string, + "evidence": string, + "severity": "low" | "medium" | "high" + } + ], + "recommended_actions": [ + { + "action": string, + "rationale": string, + "priority": "low" | "medium" | "high", + "type": "documentation" | "process" | "training" | "tooling" + } + ], + "executive_summary": string, + "narrative_summary": string +} + +Quality bars: +- Do not list a documentation_gap unless it appears in 2+ tickets. +- Do not list a process_gap unless it appears in 2+ tickets OR has severity=high in at least one. +- Recurrence clusters require at least 2 tickets. +- Every recommended_action must be specific enough that a person could pick it up tomorrow. "Improve documentation" is rejected; "Create a runbook for AMS360 App Access Key location and integration permission verification" is acceptable. +- The narrative_summary follows the same prose formatting rules as individual analyses: markdown, no headers, no filler phrases. +- The executive_summary is 3-5 sentences, plain prose.`; + +export interface AggregateReduceInput { + distributions: { + category_distribution: Record; + client_distribution: Record; + resolution_path_distribution: Record; + root_cause_distribution: Record; + date_range_actual: { earliest: string | null; latest: string | null }; + }; + fingerprints: Array<{ + ticket_number: string; + fingerprint: AggregateFingerprint; + }>; + itglue_doc_titles?: Array<{ + client_name: string; + doc_titles: string[]; + }>; +} + +export function selectReduceModel( + fingerprintCount: number, + forceOpus = false +): ModelId { + if (forceOpus) return OPUS; + // Per spec D.6: Sonnet up to 100; Opus is opt-in. Above 25, send only the + // structured fingerprints (no narrative excerpts) — handled at payload-build time. + return SONNET; +} + +function buildUserPayload(input: AggregateReduceInput): string { + const parts = [ + `=== SQL DISTRIBUTIONS ===`, + JSON.stringify(input.distributions, null, 2), + ``, + `=== FINGERPRINTS (${input.fingerprints.length} tickets) ===`, + JSON.stringify( + input.fingerprints.map((f) => ({ + ticket_number: f.ticket_number, + ...f.fingerprint, + })), + null, + 2 + ), + ]; + if (input.itglue_doc_titles && input.itglue_doc_titles.length > 0) { + parts.push( + ``, + `=== IT GLUE DOC TITLES BY CLIENT ===`, + JSON.stringify(input.itglue_doc_titles, null, 2) + ); + } else { + parts.push( + ``, + `=== IT GLUE DOC TITLES BY CLIENT ===`, + `Not included for this report. Mark itglue_check as "unable_to_verify" for documentation_gaps.` + ); + } + return parts.join('\n'); +} + +export async function runAggregateReduceStage( + input: AggregateReduceInput, + options: { forceOpus?: boolean; injectedClient?: Anthropic } = {} +): Promise & { model_used: ModelId }> { + const model = selectReduceModel(input.fingerprints.length, options.forceOpus); + const result = await callLLMStage({ + model, + system: SYSTEM_PROMPT, + user: buildUserPayload(input), + schema: AggregateReduceResponse, + maxTokens: REDUCE_MAX_TOKENS, + client: options.injectedClient, + }); + return { ...result, model_used: model }; +} + +export const _AGGREGATE_REDUCE_INTERNALS = { SYSTEM_PROMPT, REDUCE_MAX_TOKENS }; diff --git a/lib/services/analyzer/stages/stage3-deep-analysis.ts b/lib/services/analyzer/stages/stage3-deep-analysis.ts index b3fd63b..cdcbdea 100644 --- a/lib/services/analyzer/stages/stage3-deep-analysis.ts +++ b/lib/services/analyzer/stages/stage3-deep-analysis.ts @@ -41,7 +41,7 @@ Tag actor_type by the email domain we already classified for you (provided in th Respond ONLY with JSON. No prose, no code fences. Schema: { - "summary": string, + "summary": string, // markdown, 2-4 sentences, neutral status briefing "timeline": [ { "timestamp": string, @@ -57,9 +57,12 @@ Respond ONLY with JSON. No prose, no code fences. Schema: "gaps": [ { "description": string, "severity": "low" | "medium" | "high", "evidence_timestamps": string[] } ], - "next_step": string, - "next_step_rationale": string, - "post_resolution_analysis": string | null, + "next_step": string, // markdown, single concrete action; may use + // **bold** for the action verb and bullet + // sub-steps if multi-part + "next_step_rationale": string, // markdown, 1-2 short paragraphs; if there are + // 3+ competing considerations, use a bulleted list + "post_resolution_analysis": string | null, // markdown, same conventions as above "confidence_score": number, "needs_human_review": boolean, "human_review_reasons": string[], @@ -69,6 +72,32 @@ Respond ONLY with JSON. No prose, no code fences. Schema: ] } +Formatting rules for prose fields (summary, next_step, next_step_rationale, +post_resolution_analysis): + +- Output is markdown and will be rendered with a markdown renderer. Use **bold** + for emphasis on key terms or actions. Use *italics* sparingly for client-facing + language being quoted. Use bullet lists for enumerable items. + +- Do NOT use markdown headers (#, ##, ###). These fields render inside cards that + already have their own headings. + +- For summary: write as a neutral status briefing a manager could read in 10 + seconds. Lead with the most important fact. Plain language. No hedging. + +- For next_step: state the concrete action in the first sentence, with the action + verb in **bold**. If there are sub-steps, follow with a bulleted list. Address + the action to the technician, not the customer. + +- For next_step_rationale: open with one short sentence stating the core reason. + Follow with a short paragraph elaborating, OR a bulleted list if there are 3+ + distinct considerations. If there's a competing alternative worth noting, name + it explicitly: "Considered X but rejected because..." + +- Avoid filler phrases. Banned openings: "It's worth noting that...", "It's + important to understand...", "Based on the available information...", + "After reviewing the ticket...". Get to the point. + Set needs_human_review = true if any of: - confidence_score < 0.6 - gaps contain any "high" severity item diff --git a/lib/services/analyzer/stages/stage6-fingerprint.ts b/lib/services/analyzer/stages/stage6-fingerprint.ts new file mode 100644 index 0000000..92e768c --- /dev/null +++ b/lib/services/analyzer/stages/stage6-fingerprint.ts @@ -0,0 +1,135 @@ +/** + * Stage 6 — Aggregate fingerprint (Haiku). + * + * Runs after persistence (Stage 5) on every analysis. Extracts a structured + * fingerprint used by the cross-ticket aggregate report. Failure-tolerant: + * the worker logs and moves on; the analysis row stays usable, just won't + * appear in aggregate reports until re-fingerprinted via the backfill script. + * + * Spec: docs/ticket-analyzer-phase2-spec.md → Section D.3 + */ + +import { + AggregateFingerprint, + type DeepAnalysisResponse, + type OpusResponse, + type TriageResponse, +} from '@/lib/types/analyzer'; +import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call'; +import { HAIKU } from '@/lib/services/llm/models'; +import type Anthropic from '@anthropic-ai/sdk'; + +const STAGE6_MAX_TOKENS = 4_000; + +const SYSTEM_PROMPT = `You are extracting a structured fingerprint from a completed ticket analysis to enable cross-ticket aggregation. Your job is precision and consistency, not creativity. + +You will receive: +- The Sonnet-tier analysis output (summary, gaps, what_was_done, etc.) +- The triage output from Stage 1 (entities, category) +- Optionally the Opus-tier updates if deep review ran + +Produce a fingerprint matching this exact schema. Respond ONLY with JSON, no prose, no fences: + +{ + "category": string, + "subcategories": string[], + "ticket_type_inferred": string, + "root_cause_class": "configuration_drift" | "user_error" | "vendor_issue" | "hardware_failure" | "documentation_gap" | "process_gap" | "unknown" | "other", + + "client_name": string, + "vendors_involved": string[], + "applications_involved": string[], + "device_classes": string[], + + "wulf_actions_taken": string[], + "vendor_cases_opened": number, + "resolution_path": "resolved_by_wulf" | "resolved_by_vendor" | "resolved_by_client" | "unresolved" | "self_resolved_before_wulf_action", + + "documentation_gaps_observed": [ + { "description": string, "confidence": "low" | "medium" | "high" } + ], + "process_gaps_observed": [ + { "description": string, "severity": "low" | "medium" | "high" } + ], + + "similar_to_signals": string[], + "tags": string[], + + "generated_by_model": string, + "generated_at": string +} + +Strict rules: +- Use only the listed enum values for root_cause_class and resolution_path. +- documentation_gaps_observed and process_gaps_observed should each have at most 5 entries. Quality over quantity. Each entry's description must be specific enough that two different analyses describing the same underlying gap would produce similar text. +- Tags should be lowercase, hyphenated, and stable. Prefer reusing common tags (vertafore, ams360, m365-licensing, backup-veeam, etc.) over inventing new ones. +- For similar_to_signals, write short observations like "vendor case opened before checking documentation portal" or "status not advanced after customer self-resolution" — patterns the reduce step can cluster. +- generated_by_model should be the literal string "claude-haiku-4-5". +- generated_at should be the current ISO-8601 timestamp with timezone offset.`; + +export interface FingerprintInput { + triage: TriageResponse; + sonnet: DeepAnalysisResponse; + opus?: OpusResponse | null; +} + +export function buildFingerprintUserPayload(input: FingerprintInput): string { + const compactSonnet = { + summary: input.sonnet.summary, + what_was_done: input.sonnet.what_was_done, + what_should_have_been_done: input.sonnet.what_should_have_been_done, + gaps: input.sonnet.gaps, + next_step: input.sonnet.next_step, + next_step_rationale: input.sonnet.next_step_rationale, + post_resolution_analysis: input.sonnet.post_resolution_analysis, + confidence_score: input.sonnet.confidence_score, + needs_human_review: input.sonnet.needs_human_review, + human_review_reasons: input.sonnet.human_review_reasons, + itglue_docs_referenced: input.sonnet.itglue_docs_referenced.map((d) => ({ + name: d.name, + doc_type: d.doc_type, + relevance_reason: d.relevance_reason, + })), + }; + + const parts = [ + `=== TRIAGE METADATA (Stage 1) ===`, + JSON.stringify(input.triage, null, 2), + ``, + `=== ANALYSIS (Sonnet) ===`, + JSON.stringify(compactSonnet, null, 2), + ]; + + if (input.opus) { + parts.push(``, `=== DEEP-REVIEW UPDATES (Opus) ===`, JSON.stringify(input.opus, null, 2)); + } + + return parts.join('\n'); +} + +export async function runFingerprintStage( + input: FingerprintInput, + injectedClient?: Anthropic +): Promise> { + const result = await callLLMStage({ + model: HAIKU, + system: SYSTEM_PROMPT, + user: buildFingerprintUserPayload(input), + schema: AggregateFingerprint, + maxTokens: STAGE6_MAX_TOKENS, + client: injectedClient, + }); + + // Server-authoritative model and timestamp — model output for these is + // advisory; we always overwrite with truth. + return { + ...result, + data: { + ...result.data, + generated_by_model: HAIKU, + generated_at: new Date().toISOString(), + }, + }; +} + +export const _STAGE6_INTERNALS = { SYSTEM_PROMPT, STAGE6_MAX_TOKENS }; diff --git a/lib/services/analyzer/worker.test.ts b/lib/services/analyzer/worker.test.ts index e5a0cbe..0cdc24a 100644 --- a/lib/services/analyzer/worker.test.ts +++ b/lib/services/analyzer/worker.test.ts @@ -5,6 +5,7 @@ import { analyzerWorker } from './worker'; import * as dataAccess from './data-access'; import * as persistence from './persistence'; import * as pipelineModule from './pipeline'; +import * as stage6Module from './stages/stage6-fingerprint'; import type { RawTicketBundle } from './preprocessor'; const FIXTURE = JSON.parse( @@ -20,6 +21,8 @@ let insertSpy: ReturnType; let completeSpy: ReturnType; let failSpy: ReturnType; let updateStatusSpy: ReturnType; +let bulkStageSpy: ReturnType; +let insertFailedSpy: ReturnType; beforeEach(() => { loadSpy = vi.spyOn(dataAccess, 'loadTicketBundle'); @@ -30,6 +33,18 @@ beforeEach(() => { completeSpy = vi.spyOn(persistence, 'completeJob').mockResolvedValue(); failSpy = vi.spyOn(persistence, 'failJob').mockResolvedValue(); updateStatusSpy = vi.spyOn(persistence, 'updateJobStatus').mockResolvedValue(); + bulkStageSpy = vi + .spyOn(persistence, 'bulkInsertStageExecutions') + .mockResolvedValue(); + insertFailedSpy = vi + .spyOn(persistence, 'insertFailedAnalysis') + .mockResolvedValue({ id: 'failed_uuid', analysis_version: 1 }); + vi.spyOn(persistence, 'updateAnalysisFingerprint').mockResolvedValue(); + // Default Stage 6 to a no-op success in tests; worker treats failures as + // non-fatal anyway, so we just need it not to make real HTTP calls. + vi.spyOn(stage6Module, 'runFingerprintStage').mockRejectedValue( + new Error('fingerprint stub: tests do not exercise stage 6') + ); }); afterEach(() => { @@ -94,6 +109,9 @@ describe('analyzerWorker.runJob', () => { filtered_noise_count: 8, itglue_search_used: false, itglue_org_resolved: false, + triage_response: {} as never, + sonnet_response: {} as never, + opus_response: null, model_traces: {}, }); @@ -252,6 +270,9 @@ describe('analyzerWorker.runJob', () => { filtered_noise_count: 0, itglue_search_used: false, itglue_org_resolved: false, + triage_response: {} as never, + sonnet_response: {} as never, + opus_response: null, model_traces: {}, }; }) as never); diff --git a/lib/services/analyzer/worker.ts b/lib/services/analyzer/worker.ts index 39fbaf5..20a42a6 100644 --- a/lib/services/analyzer/worker.ts +++ b/lib/services/analyzer/worker.ts @@ -14,14 +14,23 @@ */ import { + bulkInsertStageExecutions, claimQueuedJob, completeJob, failJob, insertAnalysis, + insertFailedAnalysis, + updateAnalysisFingerprint, updateJobStatus, } from './persistence'; import { loadTicketBundle, TicketNotFoundError } from './data-access'; import { runPipeline, type PipelineResult } from './pipeline'; +import { runFingerprintStage } from './stages/stage6-fingerprint'; +import { HAIKU } from '@/lib/services/llm/models'; +import type { + PreprocessedTicket, + StageExecutionRecord, +} from '@/lib/types/analyzer'; const POLL_INTERVAL_MS = 2_000; @@ -82,6 +91,12 @@ class AnalyzerWorker { ticketNumber: string, triggeredByUserId: string | null ): Promise<{ analysis_id: string | null; outcome: PipelineResult['outcome'] | 'failed' }> { + // Phase 2: collect per-stage records as the pipeline runs, plus the + // preprocessed bundle, so we can persist a failed analyzer_analyses row + // (with source_snapshot) when a stage throws. + const stageRecords: StageExecutionRecord[] = []; + let capturedPre: PreprocessedTicket | null = null; + try { const bundle = await loadTicketBundle(ticketNumber); @@ -90,6 +105,12 @@ class AnalyzerWorker { {}, { onStage: (stage) => updateJobStatus(jobId, stage), + onStageRecord: (rec) => { + stageRecords.push(rec); + }, + onPreprocessed: (pre) => { + capturedPre = pre; + }, } ); @@ -117,8 +138,52 @@ class AnalyzerWorker { analysis: result.analysis, filtered_noise_count: result.filtered_noise_count, model_traces: result.model_traces, + source_snapshot: result.pre.events, }); + // Stage 6 — fingerprint. Failure-tolerant: log and continue. + const fpStart = new Date(); + let fpInputTokens: number | null = null; + let fpOutputTokens: number | null = null; + let fpOutput: unknown = {}; + let fpErr: Error | null = null; + try { + const fp = await runFingerprintStage({ + triage: result.triage_response, + sonnet: result.sonnet_response, + opus: result.opus_response, + }); + fpInputTokens = fp.usage.input_tokens; + fpOutputTokens = fp.usage.output_tokens; + fpOutput = fp.data; + await updateAnalysisFingerprint(inserted.id, fp.data); + } catch (err) { + fpErr = err instanceof Error ? err : new Error(String(err)); + console.warn( + `[ANALYZER-WORKER] fingerprint failed for analysis ${inserted.id}: ${fpErr.message}` + ); + } + const fpEnd = new Date(); + stageRecords.push({ + stage: 'fingerprint', + stage_order: 6, + model_id: HAIKU, + input_payload: { + triage_category: result.triage_response.category, + ticket_number: result.pre.header.ticket_number, + opus_used: result.opus_response !== null, + }, + output_payload: fpErr ? {} : fpOutput, + input_tokens: fpInputTokens, + output_tokens: fpOutputTokens, + latency_ms: fpEnd.getTime() - fpStart.getTime(), + started_at: fpStart, + completed_at: fpEnd, + error_message: fpErr ? fpErr.message : null, + }); + + await bulkInsertStageExecutions(inserted.id, stageRecords); + await completeJob(jobId, inserted.id); return { analysis_id: inserted.id, outcome: 'complete' }; } catch (err) { @@ -131,6 +196,37 @@ class AnalyzerWorker { err instanceof TicketNotFoundError ? `Ticket ${ticketNumber} not found in local mirror — confirm sync is current.` : message; + + // Phase 2: when we have a preprocessed bundle, persist a 'failed' + // analyzer_analyses row with source_snapshot + accumulated stage rows. + // Best-effort — if this fails we still fail the job below. + if (capturedPre !== null) { + const pre: PreprocessedTicket = capturedPre; + try { + const failedAnalysis = await insertFailedAnalysis({ + ticket_number: pre.header.ticket_number, + autotask_ticket_id: pre.header.autotask_ticket_id, + content_hash: pre.content_hash, + triggered_by_user_id: triggeredByUserId, + source_snapshot: pre.events, + filtered_noise_count: pre.counts.filtered_noise, + error_message: reason, + partial_input_tokens: 0, + partial_output_tokens: 0, + partial_cost_usd: 0, + haiku_used: stageRecords.some((r) => r.stage === 'triage'), + sonnet_used: stageRecords.some((r) => r.stage === 'analyze'), + opus_used: stageRecords.some((r) => r.stage === 'deep_review'), + }); + await bulkInsertStageExecutions(failedAnalysis.id, stageRecords); + } catch (persistErr) { + console.error( + `[ANALYZER-WORKER] failed to persist failed-analysis row for job ${jobId}:`, + persistErr + ); + } + } + await failJob(jobId, reason); return { analysis_id: null, outcome: 'failed' }; } diff --git a/lib/types/analyzer.ts b/lib/types/analyzer.ts index d52f9c9..5f3bda4 100644 --- a/lib/types/analyzer.ts +++ b/lib/types/analyzer.ts @@ -246,6 +246,206 @@ export const AnalyzerJob = z.object({ }); export type AnalyzerJob = z.infer; +// ============================================================================= +// Phase 2 — Stage 6 fingerprint schema (cross-ticket aggregation). +// ============================================================================= + +export const RootCauseClass = z.enum([ + 'configuration_drift', + 'user_error', + 'vendor_issue', + 'hardware_failure', + 'documentation_gap', + 'process_gap', + 'unknown', + 'other', +]); +export type RootCauseClass = z.infer; + +export const ResolutionPath = z.enum([ + 'resolved_by_wulf', + 'resolved_by_vendor', + 'resolved_by_client', + 'unresolved', + 'self_resolved_before_wulf_action', +]); +export type ResolutionPath = z.infer; + +export const FingerprintConfidence = z.enum(['low', 'medium', 'high']); +export type FingerprintConfidence = z.infer; + +export const AggregateFingerprint = z.object({ + category: z.string(), + subcategories: z.array(z.string()), + ticket_type_inferred: z.string(), + root_cause_class: RootCauseClass, + + client_name: z.string(), + vendors_involved: z.array(z.string()), + applications_involved: z.array(z.string()), + device_classes: z.array(z.string()), + + wulf_actions_taken: z.array(z.string()), + vendor_cases_opened: z.number().int().nonnegative(), + resolution_path: ResolutionPath, + + documentation_gaps_observed: z + .array( + z.object({ + description: z.string(), + confidence: FingerprintConfidence, + }) + ) + .max(5), + process_gaps_observed: z + .array( + z.object({ + description: z.string(), + severity: Severity, + }) + ) + .max(5), + + similar_to_signals: z.array(z.string()), + tags: z.array(z.string()), + + generated_by_model: z.string(), + generated_at: z.string().datetime({ offset: true }), +}); +export type AggregateFingerprint = z.infer; + +// ============================================================================= +// Phase 2 — Stage execution row (per-stage I/O persistence). +// ============================================================================= + +export const StageName = z.enum([ + 'preprocess', + 'triage', + 'itglue', + 'analyze', + 'deep_review', + 'fingerprint', +]); +export type StageName = z.infer; + +/** + * In-memory shape used by the pipeline to record per-stage I/O. The pipeline + * pushes one of these into the worker's array via the onStageRecord callback, + * and the worker bulk-inserts them after the analyzer_analyses row exists + * (success OR failure). + */ +export interface StageExecutionRecord { + stage: StageName; + stage_order: number; + model_id: string | null; + input_payload: unknown; + output_payload: unknown; + input_tokens: number | null; + output_tokens: number | null; + latency_ms: number | null; + started_at: Date; + completed_at: Date; + error_message: string | null; +} + +export const StageExecution = z.object({ + id: z.string().uuid(), + analysisId: z.string().uuid(), + stage: StageName, + stageOrder: z.number().int().positive(), + modelId: z.string().nullable(), + inputPayload: z.unknown(), + outputPayload: z.unknown(), + inputTokens: z.number().int().nullable(), + outputTokens: z.number().int().nullable(), + latencyMs: z.number().int().nullable(), + startedAt: z.string().datetime({ offset: true }), + completedAt: z.string().datetime({ offset: true }), + errorMessage: z.string().nullable(), +}); +export type StageExecution = z.infer; + +// ============================================================================= +// Phase 2.6 — Aggregate report (reduce step) schemas. +// ============================================================================= + +export const ITGlueCheck = z.enum([ + 'no_doc_exists', + 'doc_exists_but_unused', + 'unable_to_verify', +]); +export type ITGlueCheck = z.infer; + +export const RecommendedActionType = z.enum([ + 'documentation', + 'process', + 'training', + 'tooling', +]); +export type RecommendedActionType = z.infer; + +export const AggregateReduceResponse = z.object({ + documentation_gaps: z.array( + z.object({ + gap: z.string(), + frequency: z.number().int().nonnegative(), + example_ticket_numbers: z.array(z.string()), + evidence: z.string(), + itglue_check: ITGlueCheck, + }) + ), + process_gaps: z.array( + z.object({ + gap: z.string(), + frequency: z.number().int().nonnegative(), + severity: Severity, + example_ticket_numbers: z.array(z.string()), + evidence: z.string(), + }) + ), + client_patterns: z.array( + z.object({ + client: z.string(), + pattern: z.string(), + frequency: z.number().int().nonnegative(), + example_ticket_numbers: z.array(z.string()), + }) + ), + recurrence_clusters: z.array( + z.object({ + theme: z.string(), + ticket_numbers: z.array(z.string()), + summary: z.string(), + }) + ), + systemic_observations: z.array( + z.object({ + observation: z.string(), + evidence: z.string(), + severity: Severity, + }) + ), + recommended_actions: z.array( + z.object({ + action: z.string(), + rationale: z.string(), + priority: Severity, + type: RecommendedActionType, + }) + ), + executive_summary: z.string(), + narrative_summary: z.string(), +}); +export type AggregateReduceResponse = z.infer; + +export const AggregateReportStatus = z.enum([ + 'pending', + 'running', + 'complete', + 'failed', +]); +export type AggregateReportStatus = z.infer; + // ============================================================================= // API request bodies. // ============================================================================= diff --git a/migrations/070_analyzer_phase2_schema.sql b/migrations/070_analyzer_phase2_schema.sql new file mode 100644 index 0000000..b28eb01 --- /dev/null +++ b/migrations/070_analyzer_phase2_schema.sql @@ -0,0 +1,76 @@ +-- AI Ticket Analyzer — Phase 2 schema additions. +-- See docs/ticket-analyzer-phase2-spec.md for the full spec. +-- +-- This migration adds: +-- 1. analyzer_stage_executions — per-stage I/O for every pipeline run. +-- Replaces analyzer_analyses.model_traces (kept for back-compat for now). +-- 2. Three columns on analyzer_analyses: +-- source_snapshot — Stage 0 preprocessed events at analysis time +-- aggregate_fingerprint — Stage 6 structured fingerprint (added in phase-2.4) +-- fingerprint_generated_at — when Stage 6 succeeded (null until then) +-- +-- Phase 2.6 will add analyzer_aggregate_reports + a column + check constraint +-- on analyzer_stage_executions linking stage rows to aggregate report runs. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- ============================================================================= +-- analyzer_stage_executions +-- One row per pipeline-stage attempt. Even failed attempts insert a row. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS analyzer_stage_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + analysis_id UUID NOT NULL REFERENCES analyzer_analyses(id) ON DELETE CASCADE, + stage TEXT NOT NULL + CHECK (stage IN ('preprocess','triage','itglue','analyze','deep_review','fingerprint')), + stage_order INT NOT NULL, + model_id TEXT, + input_payload JSONB NOT NULL, + output_payload JSONB NOT NULL, + input_tokens INT, + output_tokens INT, + latency_ms INT, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NOT NULL, + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_analyzer_stage_executions_analysis_order + ON analyzer_stage_executions (analysis_id, stage_order); + +CREATE INDEX IF NOT EXISTS idx_analyzer_stage_executions_stage + ON analyzer_stage_executions (stage); + +COMMENT ON TABLE analyzer_stage_executions IS + 'Per-stage I/O for analyzer pipeline runs. One row per stage attempt, including failures. ' + 'Replaces the analyzer_analyses.model_traces JSONB blob (kept for back-compat).'; + +COMMENT ON COLUMN analyzer_stage_executions.input_payload IS + 'Exactly what was sent to the stage. For LLM stages: the system+user prompt payload.'; +COMMENT ON COLUMN analyzer_stage_executions.output_payload IS + 'Exactly what came back, pre-merge. For itglue: the redacted docs array. For deep_review: ' + 'the full Opus response including opus_notes (was previously dropped at merge time).'; + +-- ============================================================================= +-- analyzer_analyses — three new nullable columns. +-- ============================================================================= +ALTER TABLE analyzer_analyses + ADD COLUMN IF NOT EXISTS source_snapshot JSONB, + ADD COLUMN IF NOT EXISTS aggregate_fingerprint JSONB, + ADD COLUMN IF NOT EXISTS fingerprint_generated_at TIMESTAMPTZ; + +COMMENT ON COLUMN analyzer_analyses.source_snapshot IS + 'Stage 0 preprocessed event list at analysis time. Stored canonically so re-analysis or ' + 'aggregate analysis sees consistent input even if the live ticket data changes upstream.'; +COMMENT ON COLUMN analyzer_analyses.aggregate_fingerprint IS + 'Stage 6 structured fingerprint (categorization, gaps, recurrence signals) used by ' + 'aggregate cross-ticket reports. Null until Stage 6 succeeds.'; +COMMENT ON COLUMN analyzer_analyses.fingerprint_generated_at IS + 'Timestamp when aggregate_fingerprint was written. Null when fingerprint stage skipped or failed.'; +COMMENT ON COLUMN analyzer_analyses.model_traces IS + 'LEGACY (phase 1). Superseded by analyzer_stage_executions. Will be dropped once aggregate ' + 'analysis is live and stage_executions has full coverage.'; + +CREATE INDEX IF NOT EXISTS idx_analyzer_analyses_fingerprint_present + ON analyzer_analyses (id) + WHERE aggregate_fingerprint IS NOT NULL; diff --git a/migrations/071_analyzer_aggregate_reports.sql b/migrations/071_analyzer_aggregate_reports.sql new file mode 100644 index 0000000..19c04c1 --- /dev/null +++ b/migrations/071_analyzer_aggregate_reports.sql @@ -0,0 +1,86 @@ +-- AI Ticket Analyzer — Phase 2.6 aggregate reports. +-- See docs/ticket-analyzer-phase2-spec.md → Section D.4. +-- +-- Adds: +-- 1. analyzer_aggregate_reports — one row per cross-ticket report +-- 2. analyzer_stage_executions: +-- a. drop NOT NULL on analysis_id (a stage row may belong to a report instead) +-- b. add aggregate_report_id FK +-- c. add CHECK constraint enforcing exactly one of analysis_id or aggregate_report_id + +CREATE TABLE IF NOT EXISTS analyzer_aggregate_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + generated_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Inputs + filter_criteria JSONB NOT NULL, + analysis_ids UUID[] NOT NULL, + ticket_count INT NOT NULL, + include_itglue_context BOOLEAN NOT NULL DEFAULT true, + report_title TEXT, + + -- SQL-derived (computed before LLM call) + category_distribution JSONB, + client_distribution JSONB, + resolution_path_distribution JSONB, + root_cause_distribution JSONB, + date_range_actual JSONB, + + -- LLM-derived + documentation_gaps JSONB, + process_gaps JSONB, + client_patterns JSONB, + recurrence_clusters JSONB, + systemic_observations JSONB, + recommended_actions JSONB, + narrative_summary TEXT, + executive_summary TEXT, + + -- Metadata + total_input_tokens INT, + total_output_tokens INT, + estimated_cost_usd NUMERIC(10,4), + model_used TEXT, + itglue_context_included BOOLEAN, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','running','complete','failed')), + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_analyzer_aggregate_reports_user_date + ON analyzer_aggregate_reports (generated_by_user_id, generated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_analyzer_aggregate_reports_analysis_ids + ON analyzer_aggregate_reports USING gin (analysis_ids); + +CREATE INDEX IF NOT EXISTS idx_analyzer_aggregate_reports_pending + ON analyzer_aggregate_reports (status, generated_at) + WHERE status IN ('pending','running'); + +COMMENT ON TABLE analyzer_aggregate_reports IS + 'Cross-ticket aggregate reports. Inputs (filter_criteria, analysis_ids) are immutable; outputs (LLM-derived fields) are written once when the runner completes.'; + +-- analyzer_stage_executions: relax analysis_id, add aggregate_report_id, enforce mutual exclusion. +ALTER TABLE analyzer_stage_executions + ALTER COLUMN analysis_id DROP NOT NULL; + +ALTER TABLE analyzer_stage_executions + ADD COLUMN IF NOT EXISTS aggregate_report_id UUID + REFERENCES analyzer_aggregate_reports(id) ON DELETE CASCADE; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'analyzer_stage_executions_parent_check' + ) THEN + ALTER TABLE analyzer_stage_executions + ADD CONSTRAINT analyzer_stage_executions_parent_check + CHECK ((analysis_id IS NOT NULL) <> (aggregate_report_id IS NOT NULL)); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_analyzer_stage_executions_aggregate_report + ON analyzer_stage_executions (aggregate_report_id, stage_order) + WHERE aggregate_report_id IS NOT NULL; diff --git a/migrations/072_analyzer_cost_audit.sql b/migrations/072_analyzer_cost_audit.sql new file mode 100644 index 0000000..5ab551a --- /dev/null +++ b/migrations/072_analyzer_cost_audit.sql @@ -0,0 +1,26 @@ +-- AI Ticket Analyzer — Phase 2.7 cost-guard audit log. +-- See docs/ticket-analyzer-phase2-spec.md → Section D.8. + +CREATE TABLE IF NOT EXISTS analyzer_cost_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, + action TEXT NOT NULL, + -- e.g. 'aggregate_report' | 'analyze_ticket' + estimated_cost NUMERIC(10,4) NOT NULL, + daily_spend_before NUMERIC(10,4) NOT NULL, + decision TEXT NOT NULL + CHECK (decision IN ('approved','requires_confirmation','blocked','overridden')), + decision_reason TEXT, + context JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_analyzer_cost_audit_user_date + ON analyzer_cost_audit (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_analyzer_cost_audit_decision + ON analyzer_cost_audit (decision, created_at DESC); + +COMMENT ON TABLE analyzer_cost_audit IS + 'Audit log of cost-guard decisions for analyzer LLM operations. One row per gated request ' + '(approved/requires_confirmation/blocked/overridden), recording the inputs that drove the decision.'; diff --git a/package-lock.json b/package-lock.json index 0b5df26..7b9db5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", "@types/node-cron": "^3.0.11", "better-auth": "^1.4.10", @@ -45,8 +46,10 @@ "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "react-hook-form": "^7.70.0", + "react-markdown": "^10.1.0", "recharts": "^3.7.0", "redis": "^5.10.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.3.5" @@ -5224,6 +5227,18 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -5342,6 +5357,15 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5353,9 +5377,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "devOptional": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5370,6 +5411,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -5411,7 +5467,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5427,6 +5482,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -5734,6 +5795,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -6434,6 +6501,16 @@ "@babel/types": "^7.26.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6834,6 +6911,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6861,6 +6948,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chevrotain": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", @@ -6961,6 +7088,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -7013,11 +7150,22 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -7241,6 +7389,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -7363,6 +7524,15 @@ "node": ">=0.10" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -7384,6 +7554,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -8217,6 +8400,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -8268,6 +8461,12 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8780,6 +8979,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -8797,6 +9036,16 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8876,6 +9125,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8924,6 +9179,30 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9082,6 +9361,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -9156,6 +9445,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -9227,6 +9526,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9903,6 +10214,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9944,6 +10265,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9954,6 +10285,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -9964,6 +10577,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -10498,6 +11674,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10695,6 +11896,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10831,6 +12045,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -10970,6 +12194,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -11221,6 +12472,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -11732,6 +13049,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -11904,6 +13231,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11940,6 +13281,24 @@ ], "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -12003,7 +13362,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -12141,6 +13499,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -12368,6 +13746,93 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -12501,6 +13966,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -13207,6 +14700,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 3b018a6..24f822a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", "@types/node-cron": "^3.0.11", "better-auth": "^1.4.10", @@ -48,8 +49,10 @@ "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "react-hook-form": "^7.70.0", + "react-markdown": "^10.1.0", "recharts": "^3.7.0", "redis": "^5.10.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.3.5" diff --git a/scripts/backfill-fingerprints.ts b/scripts/backfill-fingerprints.ts new file mode 100644 index 0000000..df3bb45 --- /dev/null +++ b/scripts/backfill-fingerprints.ts @@ -0,0 +1,150 @@ +/** + * backfill-fingerprints.ts + * + * Generates aggregate_fingerprint for analyzer_analyses rows that are missing + * one. Reads triage_response + sonnet_response (+ optional opus_response) from + * the legacy model_traces JSONB column on each analysis, runs Stage 6, and + * writes the result back via updateAnalysisFingerprint. + * + * Idempotent — analyses with a fingerprint already in place are skipped at + * the SQL filter level, so re-running is safe. + * + * Usage: + * npx tsx scripts/backfill-fingerprints.ts # process all + * npx tsx scripts/backfill-fingerprints.ts --limit=50 # cap work + * npx tsx scripts/backfill-fingerprints.ts --dry-run # show what would run + * + * Spec: docs/ticket-analyzer-phase2-spec.md → Section A.3 + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../.env.local') }); +config({ path: resolve(__dirname, '../.env') }); + +// When run from the host (not inside the docker network), POSTGRES_HOST is +// 'postgres' which won't resolve. Fall back to localhost. +if (process.env.POSTGRES_HOST === 'postgres') { + process.env.POSTGRES_HOST = 'localhost'; +} + +import postgresClient from '../lib/services/postgres-client'; +import { runFingerprintStage } from '../lib/services/analyzer/stages/stage6-fingerprint'; +import { updateAnalysisFingerprint } from '../lib/services/analyzer/persistence'; +import { + DeepAnalysisResponse, + OpusResponse, + TriageResponse, +} from '../lib/types/analyzer'; + +interface BackfillRow { + id: string; + ticket_number: string; + analysis_version: number; + model_traces: Record | null; +} + +const BATCH_SIZE = 10; + +function parseArgs() { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const limitArg = args.find((a) => a.startsWith('--limit=')); + const limit = limitArg + ? Math.max(0, Number(limitArg.split('=')[1])) + : Number.POSITIVE_INFINITY; + return { dryRun, limit }; +} + +async function main() { + const { dryRun, limit } = parseArgs(); + console.log( + `[backfill-fingerprints] starting (dryRun=${dryRun}, limit=${ + Number.isFinite(limit) ? limit : 'unlimited' + })` + ); + + let processed = 0; + let succeeded = 0; + let skipped = 0; + let failed = 0; + let totalCost = 0; + + // Loop in batches until we run out of work or hit the limit. + while (processed < limit) { + const remaining = Math.min(BATCH_SIZE, limit - processed); + const res = await postgresClient.query( + `SELECT id::text AS id, ticket_number, analysis_version, model_traces + FROM analyzer_analyses + WHERE aggregate_fingerprint IS NULL + AND status = 'complete' + ORDER BY triggered_at ASC + LIMIT $1`, + [remaining] + ); + + if (res.rowCount === 0) break; + + for (const row of res.rows) { + processed++; + const tag = `${row.ticket_number} v${row.analysis_version}`; + + const traces = row.model_traces ?? {}; + const tRaw = (traces as Record).triage_response; + const sRaw = (traces as Record).sonnet_response; + const oRaw = (traces as Record).opus_response; + + if (!tRaw || !sRaw) { + console.log(`[skip] ${tag} — model_traces missing triage/sonnet`); + skipped++; + continue; + } + + let triage, sonnet, opus; + try { + triage = TriageResponse.parse(tRaw); + sonnet = DeepAnalysisResponse.parse(sRaw); + opus = oRaw ? OpusResponse.parse(oRaw) : null; + } catch (err) { + console.log( + `[skip] ${tag} — model_traces shape unrecognized: ${ + err instanceof Error ? err.message : String(err) + }` + ); + skipped++; + continue; + } + + if (dryRun) { + console.log(`[dry] would fingerprint ${tag}`); + continue; + } + + try { + const fp = await runFingerprintStage({ triage, sonnet, opus }); + await updateAnalysisFingerprint(row.id, fp.data); + totalCost += fp.estimated_cost_usd; + succeeded++; + console.log( + `[ok] ${tag} (cost $${fp.estimated_cost_usd.toFixed(4)}, total $${totalCost.toFixed(4)})` + ); + } catch (err) { + failed++; + console.error( + `[err] ${tag}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + + console.log( + `[backfill-fingerprints] done. processed=${processed} succeeded=${succeeded} skipped=${skipped} failed=${failed} totalCost=$${totalCost.toFixed(4)}` + ); + process.exit(failed > 0 && succeeded === 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('[backfill-fingerprints] unhandled error:', err); + process.exit(1); +}); From 98843a80ba1f232b42f9e86dc29ea234cfdd55c3 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 14:26:50 -0400 Subject: [PATCH 4/8] fix(analyzer): multi-select option click swallowed by nested Radix button Radix Checkbox renders as +
); }) )} From 9acf48e78ae46611a6ca1b7b008fedc4a3e7f4f6 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 14:36:22 -0400 Subject: [PATCH 5/8] fix(analyzer): priorities has no is_deleted column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter-options endpoint was rejecting from the priorities subquery, and because all six lookups run in Promise.all the whole endpoint failed with HTTP 500 — leaving every multi-select dropdown empty including Client. priorities is a small reference table with no soft-delete; just filter on is_active. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/analyzer/tickets/filter-options/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/analyzer/tickets/filter-options/route.ts b/app/api/analyzer/tickets/filter-options/route.ts index 0be4b9b..6f3c8c4 100644 --- a/app/api/analyzer/tickets/filter-options/route.ts +++ b/app/api/analyzer/tickets/filter-options/route.ts @@ -52,8 +52,9 @@ export async function GET() { ORDER BY sort_order NULLS LAST, label` ), postgresClient.query( + // priorities has no is_deleted column (no soft-delete on this small table) `SELECT value, label FROM priorities - WHERE is_active = true AND is_deleted = false + WHERE is_active = true ORDER BY value` ), postgresClient.query( From a0a6e7f1929796fde743aaebb8d7095a3a908d82 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 14:48:53 -0400 Subject: [PATCH 6/8] fix(itglue): list flexible assets per type to satisfy API 422 requirement IT Glue's /flexible_assets endpoint refuses requests without a filter[flexible-asset-type-id] (returns 422 "Cannot index flexible assets without providing a flexible asset type ID filter"). The analyzer's Stage 2 search was caught and tolerated, but never returned docs. Added getFlexibleAssetsForOrganization(orgId) on ITGlueClient. It fetches the type list once per process (memoized), then fans out per-type fetches with Promise.allSettled so a permission-restricted type doesn't poison the whole org. Wired into itglue-search and aggregate-persistence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../analyzer/aggregate-persistence.ts | 2 +- lib/services/analyzer/itglue-search.ts | 2 +- lib/services/itglue-client.ts | 46 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/services/analyzer/aggregate-persistence.ts b/lib/services/analyzer/aggregate-persistence.ts index 725c56f..ad803b7 100644 --- a/lib/services/analyzer/aggregate-persistence.ts +++ b/lib/services/analyzer/aggregate-persistence.ts @@ -237,7 +237,7 @@ async function fetchITGlueDocTitles( try { const org = await client.findOrganizationByName(name); if (!org) continue; - const docs = await client.getFlexibleAssets({ organizationId: org.id }); + const docs = await client.getFlexibleAssetsForOrganization(org.id); const titles = docs .map((d) => (d as { name?: string }).name) .filter((t): t is string => typeof t === 'string') diff --git a/lib/services/analyzer/itglue-search.ts b/lib/services/analyzer/itglue-search.ts index 3ce2b83..fdfb405 100644 --- a/lib/services/analyzer/itglue-search.ts +++ b/lib/services/analyzer/itglue-search.ts @@ -153,7 +153,7 @@ export async function itglueSearch( // Flexible assets — runbooks, integrations, app-specific docs. try { - const flex = await client.getFlexibleAssets({ organizationId: resolved.org_id }); + const flex = await client.getFlexibleAssetsForOrganization(resolved.org_id); for (const a of flex) { if (docs.length >= MAX_DOCS_RETURNED) break; const dedupeKey = `flex:${a.id}`; diff --git a/lib/services/itglue-client.ts b/lib/services/itglue-client.ts index 6cb1667..13e507a 100644 --- a/lib/services/itglue-client.ts +++ b/lib/services/itglue-client.ts @@ -286,6 +286,52 @@ export class ITGlueClient { })); } + /** + * Cached, per-instance fetch of enabled flexible asset type ids. IT Glue's + * /flexible_assets endpoint requires a flexibleAssetTypeId filter (otherwise + * 422). This caches the type list so callers don't pay the lookup on every + * call. Cache lives for the life of the process — types rarely change. + */ + private flexibleAssetTypesCache: Promise | null = null; + private async cachedFlexibleAssetTypes(): Promise { + if (!this.flexibleAssetTypesCache) { + this.flexibleAssetTypesCache = this.getFlexibleAssetTypes().catch((err) => { + // Re-throw next call so a transient failure isn't sticky. + this.flexibleAssetTypesCache = null; + throw err; + }); + } + return this.flexibleAssetTypesCache; + } + + /** + * Get every flexible asset for a given organization across all enabled types. + * IT Glue's /flexible_assets endpoint requires a per-type filter (the + * `getFlexibleAssets` raw call returns 422 otherwise), so this helper + * enumerates types and fans out per-type requests in parallel. Per-type + * failures are tolerated so a single bad type doesn't poison the whole org. + */ + async getFlexibleAssetsForOrganization( + organizationId: number | string + ): Promise { + const types = await this.cachedFlexibleAssetTypes(); + const enabled = types.filter((t) => t.enabled); + const results = await Promise.allSettled( + enabled.map((t) => + this.getFlexibleAssets({ + organizationId, + flexibleAssetTypeId: t.id, + }) + ) + ); + const out: ITGlueFlexibleAsset[] = []; + for (const r of results) { + if (r.status === 'fulfilled') out.push(...r.value); + // Tolerate per-type failures — common for permission-restricted types. + } + return out; + } + // ─── Configurations ─────────────────────────────────────────────────────── private mapConfiguration(item: any): ITGlueConfiguration { From 378e68ad8a3e127084ee2c457e2f598550c93e43 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 29 Apr 2026 14:56:00 -0400 Subject: [PATCH 7/8] fix(analyzer): reset stale in-flight jobs on worker boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container restart leaves analyzer_jobs rows stuck in fetching/triaging/itglue/analyzing/deep_review forever — the worker's claimQueuedJob only picks up status='queued', so a job mid-pipeline when the process died gets orphaned. resetStaleJobsToQueued() reverts any active-state row whose started_at is older than 10 min back to 'queued' with started_at=NULL. The worker calls it once on start() before scheduling the first poll. 10 min is 3x the realistic pipeline ceiling — well past Sonnet+Opus combined. Logs the count when nonzero so restarts that recover work are visible. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/services/analyzer/persistence.ts | 22 ++++++++++++++++++++++ lib/services/analyzer/worker.ts | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/services/analyzer/persistence.ts b/lib/services/analyzer/persistence.ts index 2265b5e..a87f7a2 100644 --- a/lib/services/analyzer/persistence.ts +++ b/lib/services/analyzer/persistence.ts @@ -329,6 +329,28 @@ export async function completeJob( ); } +/** + * Reclaim orphaned in-flight jobs whose started_at is older than the threshold. + * Called once on worker boot — any job in an active state (fetching/triaging/ + * itglue/analyzing/deep_review) that's been "running" longer than the expected + * pipeline ceiling is almost certainly orphaned by a container restart and + * needs to be re-queued. Returns the number of rows reset. + */ +export async function resetStaleJobsToQueued( + thresholdMinutes = 10 +): Promise { + const res = await postgresClient.query( + `UPDATE analyzer_jobs + SET status = 'queued', + started_at = NULL + WHERE status IN ('fetching','triaging','itglue','analyzing','deep_review') + AND started_at IS NOT NULL + AND started_at < NOW() - ($1::int || ' minutes')::interval`, + [thresholdMinutes] + ); + return res.rowCount ?? 0; +} + export async function failJob(jobId: string, errorMessage: string): Promise { await postgresClient.query( `UPDATE analyzer_jobs diff --git a/lib/services/analyzer/worker.ts b/lib/services/analyzer/worker.ts index 20a42a6..213f88b 100644 --- a/lib/services/analyzer/worker.ts +++ b/lib/services/analyzer/worker.ts @@ -20,6 +20,7 @@ import { failJob, insertAnalysis, insertFailedAnalysis, + resetStaleJobsToQueued, updateAnalysisFingerprint, updateJobStatus, } from './persistence'; @@ -33,6 +34,7 @@ import type { } from '@/lib/types/analyzer'; const POLL_INTERVAL_MS = 2_000; +const STALE_JOB_RESET_MINUTES = 10; class AnalyzerWorker { private timer: NodeJS.Timeout | null = null; @@ -43,6 +45,21 @@ class AnalyzerWorker { if (this.running) return; this.running = true; console.log('[ANALYZER-WORKER] starting; polling every 2s'); + + // Reclaim jobs orphaned by a previous restart. Any active-state job + // whose started_at is older than the pipeline ceiling is presumed + // orphaned and gets reset to 'queued' so this worker can re-claim it. + try { + const reset = await resetStaleJobsToQueued(STALE_JOB_RESET_MINUTES); + if (reset > 0) { + console.log( + `[ANALYZER-WORKER] reset ${reset} stale in-flight job(s) to queued (older than ${STALE_JOB_RESET_MINUTES}min)` + ); + } + } catch (err) { + console.error('[ANALYZER-WORKER] stale-job reset failed:', err); + } + this.scheduleNextPoll(0); } From 1112a06afeb5430e4dce7a45e1e30b7169b05931 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 07:13:18 -0400 Subject: [PATCH 8/8] 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) --- app/admin/device-link-conflicts/page.tsx | 253 +++++ app/admin/itglue-writes/page.tsx | 168 +++ app/admin/page.tsx | 321 ++++++ app/admin/rmm-overshell/page.tsx | 272 +++++ app/analyzer/analysis/[id]/page.tsx | 8 +- .../itglue/applications/[id]/page.tsx | 800 +++++++++++++++ app/analyzer/itglue/applications/page.tsx | 155 +++ .../itglue/configurations/[id]/page.tsx | 762 ++++++++++++++ app/analyzer/itglue/configurations/page.tsx | 156 +++ .../itglue/sites/[companyId]/page.tsx | 234 +++++ app/analyzer/ticket/[ticketNumber]/page.tsx | 113 ++- .../[id]/resolve/route.ts | 98 ++ app/api/admin/device-link-conflicts/route.ts | 133 +++ .../rmm/settings/discover-loglift/route.ts | 41 + app/api/admin/rmm/settings/discover/route.ts | 38 + app/api/admin/rmm/settings/route.ts | 61 ++ .../analyses/[id]/itglue-suggestions/route.ts | 144 +++ app/api/analyzer/analyses/[id]/share/route.ts | 34 + .../itglue/applications/[id]/apply/route.ts | 201 ++++ .../itglue/applications/[id]/audit/route.ts | 113 +++ .../[id]/revert/[writeId]/route.ts | 146 +++ .../itglue/applications/[id]/route.ts | 88 ++ .../itglue/applications/[id]/writes/route.ts | 22 + .../itglue/applications/[id]/xrefs/route.ts | 21 + app/api/analyzer/itglue/applications/route.ts | 76 ++ .../itglue/configurations/[id]/apply/route.ts | 215 ++++ .../itglue/configurations/[id]/audit/route.ts | 104 ++ .../[id]/revert/[writeId]/route.ts | 147 +++ .../itglue/configurations/[id]/route.ts | 135 +++ .../configurations/[id]/writes/route.ts | 24 + .../itglue/configurations/[id]/xrefs/route.ts | 18 + .../analyzer/itglue/configurations/route.ts | 76 ++ .../itglue/sites/[companyId]/route.ts | 67 ++ app/api/analyzer/itglue/writes/route.ts | 39 + app/api/analyzer/share/recipients/route.ts | 105 ++ .../[ticketNumber]/analyze-bundle/route.ts | 257 +++++ .../tickets/[ticketNumber]/analyze/route.ts | 10 +- .../[ticketNumber]/itglue-xrefs/route.ts | 21 + .../tickets/[ticketNumber]/links/route.ts | 97 ++ app/api/dashboard/integration-health/route.ts | 31 + app/api/dashboard/overview/route.ts | 168 +++ app/api/rmm/executions/[id]/route.ts | 24 + app/api/rmm/executions/route.ts | 118 +++ app/api/rmm/loglift/upload/route.ts | 99 ++ app/api/rmm/scripts/route.ts | 25 + app/api/sync/schedules/reload/route.ts | 27 + app/configuration-items/page.tsx | 26 +- app/dashboard/page.tsx | 757 +++++++------- components/admin/SyncScheduler.tsx | 31 +- components/analyzer/analyze-button.tsx | 10 +- .../analyzer/itglue-suggestions-panel.tsx | 493 +++++++++ components/analyzer/provider-toggle.tsx | 75 ++ components/analyzer/related-tickets-panel.tsx | 368 +++++++ components/analyzer/share-modal.tsx | 208 +++- components/navigation/app-navigation.tsx | 132 +-- components/rmm/rmm-dispatch-dialog.tsx | 189 ++++ components/rmm/rmm-execution-stream.tsx | 183 ++++ components/rmm/rmm-script-picker.tsx | 198 ++++ docs/LogLift Review.json | 362 +++++++ docs/itglue-asset-audit-spec.md | 604 +++++++++++ docs/loglift-eventlog-pipeline-spec.md | 271 +++++ docs/rmm-overshell-evidence-spec.md | 412 ++++++++ .../wulf-pulse-ticket-analyzer-build-notes.md | 761 ++++++++++++++ docs/wulf-pulse-ticket-analyzer-runbook.md | 512 ++++++++++ lib/permissions.ts | 12 + .../analyzer/aggregate-persistence.ts | 147 ++- .../analyzer/asset-audit/asset-matcher.ts | 264 +++++ .../analyzer/asset-audit/data-builder.ts | 719 +++++++++++++ .../analyzer/asset-audit/persistence.ts | 451 ++++++++ lib/services/analyzer/asset-audit/prompt.ts | 236 +++++ .../analyzer/asset-audit/runner.test.ts | 204 ++++ lib/services/analyzer/asset-audit/runner.ts | 136 +++ lib/services/analyzer/asset-audit/xrefs.ts | 223 ++++ lib/services/analyzer/data-access.ts | 5 +- .../fixtures/T20260424.0045.input.json | 3 +- lib/services/analyzer/link-discovery.test.ts | 340 +++++++ lib/services/analyzer/link-discovery.ts | 441 ++++++++ lib/services/analyzer/persistence.ts | 68 +- lib/services/analyzer/pipeline.ts | 38 +- lib/services/analyzer/preprocessor.ts | 1 + .../analyzer/stages/aggregate-reduce.ts | 21 +- lib/services/analyzer/stages/stage1-triage.ts | 7 +- .../analyzer/stages/stage3-deep-analysis.ts | 7 +- .../analyzer/stages/stage4-deep-reasoning.ts | 7 +- .../analyzer/stages/stage6-fingerprint.ts | 10 +- lib/services/analyzer/worker.ts | 109 +- lib/services/b2/client.test.ts | 130 +++ lib/services/b2/client.ts | 211 ++++ lib/services/datto-rmm-client.ts | 34 + lib/services/device-link-reconciler.ts | 249 +++++ lib/services/email.ts | 317 ++++-- lib/services/integration-health-alerts.ts | 159 +++ lib/services/integration-health.ts | 316 ++++++ lib/services/itglue-client.ts | 95 ++ lib/services/itglue-sync-service.ts | 109 ++ lib/services/llm/call.ts | 127 ++- lib/services/llm/models.ts | 66 +- lib/services/llm/openrouter-call.ts | 126 +++ lib/services/llm/pricing.ts | 29 +- lib/services/rmm/executor.ts | 436 ++++++++ lib/services/rmm/loglift-matcher.ts | 127 +++ lib/services/rmm/loglift-receiver.ts | 476 +++++++++ lib/services/rmm/persistence.ts | 488 +++++++++ lib/services/rmm/scripts/get-ad-health.ts | 102 ++ lib/services/rmm/scripts/get-dhcp-scopes.ts | 57 ++ lib/services/rmm/scripts/get-dns-zones.ts | 49 + .../rmm/scripts/get-event-log-recent.ts | 39 + .../rmm/scripts/get-installed-software.ts | 36 + .../rmm/scripts/get-network-discovery.ts | 67 ++ lib/services/rmm/scripts/get-services.ts | 34 + lib/services/rmm/scripts/index.ts | 57 ++ lib/services/rmm/scripts/loglift-eventlogs.ts | 36 + lib/services/rmm/scripts/registry.test.ts | 80 ++ lib/services/rmm/scripts/types.ts | 78 ++ lib/services/rmm/settings.ts | 189 ++++ lib/services/rmm/target-resolver.test.ts | 23 + lib/services/rmm/target-resolver.ts | 192 ++++ lib/services/rmm/worker.test.ts | 59 ++ lib/services/rmm/worker.ts | 241 +++++ lib/services/sync-scheduler.ts | 50 +- lib/types/analyzer.ts | 114 +++ middleware.ts | 2 + .../073_analyzer_link_aware_bundles.sql | 44 + migrations/074_analyzer_provider.sql | 38 + migrations/075_itglue_audit.sql | 87 ++ migrations/076_itglue_ticket_xrefs.sql | 83 ++ migrations/077_rmm_overshell.sql | 88 ++ migrations/078_loglift_uploads.sql | 43 + migrations/079_endpoint_data_model.sql | 303 ++++++ migrations/080_device_xref_company_id.sql | 86 ++ .../loglift/EventLogCollector-DattoRMM.ps1 | 960 ++++++++++++++++++ scripts/reconcile-device-links.ts | 57 ++ 132 files changed, 21352 insertions(+), 743 deletions(-) create mode 100644 app/admin/device-link-conflicts/page.tsx create mode 100644 app/admin/itglue-writes/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/rmm-overshell/page.tsx create mode 100644 app/analyzer/itglue/applications/[id]/page.tsx create mode 100644 app/analyzer/itglue/applications/page.tsx create mode 100644 app/analyzer/itglue/configurations/[id]/page.tsx create mode 100644 app/analyzer/itglue/configurations/page.tsx create mode 100644 app/analyzer/itglue/sites/[companyId]/page.tsx create mode 100644 app/api/admin/device-link-conflicts/[id]/resolve/route.ts create mode 100644 app/api/admin/device-link-conflicts/route.ts create mode 100644 app/api/admin/rmm/settings/discover-loglift/route.ts create mode 100644 app/api/admin/rmm/settings/discover/route.ts create mode 100644 app/api/admin/rmm/settings/route.ts create mode 100644 app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/apply/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/audit/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/writes/route.ts create mode 100644 app/api/analyzer/itglue/applications/[id]/xrefs/route.ts create mode 100644 app/api/analyzer/itglue/applications/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/apply/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/audit/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/writes/route.ts create mode 100644 app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts create mode 100644 app/api/analyzer/itglue/configurations/route.ts create mode 100644 app/api/analyzer/itglue/sites/[companyId]/route.ts create mode 100644 app/api/analyzer/itglue/writes/route.ts create mode 100644 app/api/analyzer/share/recipients/route.ts create mode 100644 app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts create mode 100644 app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts create mode 100644 app/api/analyzer/tickets/[ticketNumber]/links/route.ts create mode 100644 app/api/dashboard/integration-health/route.ts create mode 100644 app/api/dashboard/overview/route.ts create mode 100644 app/api/rmm/executions/[id]/route.ts create mode 100644 app/api/rmm/executions/route.ts create mode 100644 app/api/rmm/loglift/upload/route.ts create mode 100644 app/api/rmm/scripts/route.ts create mode 100644 app/api/sync/schedules/reload/route.ts create mode 100644 components/analyzer/itglue-suggestions-panel.tsx create mode 100644 components/analyzer/provider-toggle.tsx create mode 100644 components/analyzer/related-tickets-panel.tsx create mode 100644 components/rmm/rmm-dispatch-dialog.tsx create mode 100644 components/rmm/rmm-execution-stream.tsx create mode 100644 components/rmm/rmm-script-picker.tsx create mode 100644 docs/LogLift Review.json create mode 100644 docs/itglue-asset-audit-spec.md create mode 100644 docs/loglift-eventlog-pipeline-spec.md create mode 100644 docs/rmm-overshell-evidence-spec.md create mode 100644 lib/services/analyzer/asset-audit/asset-matcher.ts create mode 100644 lib/services/analyzer/asset-audit/data-builder.ts create mode 100644 lib/services/analyzer/asset-audit/persistence.ts create mode 100644 lib/services/analyzer/asset-audit/prompt.ts create mode 100644 lib/services/analyzer/asset-audit/runner.test.ts create mode 100644 lib/services/analyzer/asset-audit/runner.ts create mode 100644 lib/services/analyzer/asset-audit/xrefs.ts create mode 100644 lib/services/analyzer/link-discovery.test.ts create mode 100644 lib/services/analyzer/link-discovery.ts create mode 100644 lib/services/b2/client.test.ts create mode 100644 lib/services/b2/client.ts create mode 100644 lib/services/device-link-reconciler.ts create mode 100644 lib/services/integration-health-alerts.ts create mode 100644 lib/services/integration-health.ts create mode 100644 lib/services/llm/openrouter-call.ts create mode 100644 lib/services/rmm/executor.ts create mode 100644 lib/services/rmm/loglift-matcher.ts create mode 100644 lib/services/rmm/loglift-receiver.ts create mode 100644 lib/services/rmm/persistence.ts create mode 100644 lib/services/rmm/scripts/get-ad-health.ts create mode 100644 lib/services/rmm/scripts/get-dhcp-scopes.ts create mode 100644 lib/services/rmm/scripts/get-dns-zones.ts create mode 100644 lib/services/rmm/scripts/get-event-log-recent.ts create mode 100644 lib/services/rmm/scripts/get-installed-software.ts create mode 100644 lib/services/rmm/scripts/get-network-discovery.ts create mode 100644 lib/services/rmm/scripts/get-services.ts create mode 100644 lib/services/rmm/scripts/index.ts create mode 100644 lib/services/rmm/scripts/loglift-eventlogs.ts create mode 100644 lib/services/rmm/scripts/registry.test.ts create mode 100644 lib/services/rmm/scripts/types.ts create mode 100644 lib/services/rmm/settings.ts create mode 100644 lib/services/rmm/target-resolver.test.ts create mode 100644 lib/services/rmm/target-resolver.ts create mode 100644 lib/services/rmm/worker.test.ts create mode 100644 lib/services/rmm/worker.ts create mode 100644 migrations/073_analyzer_link_aware_bundles.sql create mode 100644 migrations/074_analyzer_provider.sql create mode 100644 migrations/075_itglue_audit.sql create mode 100644 migrations/076_itglue_ticket_xrefs.sql create mode 100644 migrations/077_rmm_overshell.sql create mode 100644 migrations/078_loglift_uploads.sql create mode 100644 migrations/079_endpoint_data_model.sql create mode 100644 migrations/080_device_xref_company_id.sql create mode 100644 scripts/loglift/EventLogCollector-DattoRMM.ps1 create mode 100644 scripts/reconcile-device-links.ts diff --git a/app/admin/device-link-conflicts/page.tsx b/app/admin/device-link-conflicts/page.tsx new file mode 100644 index 0000000..3096370 --- /dev/null +++ b/app/admin/device-link-conflicts/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react'; + +interface Candidate { + ciId: string; + confidence: string | null; + hostname: string | null; + serial: string | null; + mac: string | null; + companyId: string | null; + companyName: string | null; + isDeleted: boolean; +} + +interface Review { + id: string; + detectedAt: string; + xref: { + id: string; + source: string; + sourceId: string; + hostname: string | null; + serial: string | null; + mac: string | null; + companyId: string | null; + companyName: string | null; + lastSeenAt: string | null; + }; + candidates: Candidate[]; +} + +const SOURCES = ['all', 'datto_rmm', 'itglue', 's1', 'veeam'] as const; +type SourceFilter = (typeof SOURCES)[number]; + +function confidenceColor(c: string | null): 'default' | 'secondary' | 'outline' { + if (c === 'exact_serial') return 'default'; + if (c === 'mac') return 'default'; + if (c === 'hostname_in_company') return 'secondary'; + return 'outline'; +} + +export default function DeviceLinkConflictsPage() { + const [items, setItems] = useState(null); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [source, setSource] = useState('all'); + const [resolving, setResolving] = useState(null); + + async function load(): Promise { + setError(null); + setItems(null); + try { + const params = new URLSearchParams({ limit: '100' }); + if (source !== 'all') params.set('source', source); + const res = await fetch(`/api/admin/device-link-conflicts?${params}`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as { items: Review[]; total: number }; + setItems(data.items); + setTotal(data.total); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void load(); + }, [source]); + + async function resolve(reviewId: string, ciId: string): Promise { + setResolving(`${reviewId}:${ciId}`); + try { + const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ciId }), + }); + const data = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`); + toast.success(`Linked to CI ${ciId}`); + setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null); + setTotal((t) => Math.max(0, t - 1)); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Resolve failed'); + } finally { + setResolving(null); + } + } + + return ( +
+ + + + + Device-link conflicts + + + +

+ Cases where the reconciler found two or more configuration_items + matching one external device record. Pick the right CI to break the + tie. Skipped rows stay unlinked until resolved. +

+ +
+ Source: + + + {total} unresolved {total === 1 ? 'conflict' : 'conflicts'} + +
+ + {error && ( + + Failed to load + {error} + + )} + + {items === null && !error && ( +
+ + + +
+ )} + + {items !== null && items.length === 0 && !error && ( + + + No conflicts + + Nothing waiting for review on this filter. + + + )} + + {items?.map((r) => ( + + +
+
+
+ {r.xref.source}:{r.xref.sourceId} +
+
+ {r.xref.hostname ?? '(no hostname)'} + {r.xref.companyName && ( + + @ {r.xref.companyName} + + )} +
+
+ + {r.candidates.length} candidates + +
+
+ {r.xref.serial && serial: {r.xref.serial}} + {r.xref.mac && mac: {r.xref.mac}} + {r.xref.lastSeenAt && ( + last seen: {new Date(r.xref.lastSeenAt).toLocaleString()} + )} +
+
+ + {r.candidates.map((c) => { + const isResolving = resolving === `${r.id}:${c.ciId}`; + return ( +
+
+
+ + {c.hostname ?? '(no hostname)'} + + {c.isDeleted && ( + + deleted + + )} + {c.confidence && ( + + {c.confidence} + + )} +
+
+ CI {c.ciId} + {c.serial && serial: {c.serial}} + {c.companyName && @ {c.companyName}} +
+
+ +
+ ); + })} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/admin/itglue-writes/page.tsx b/app/admin/itglue-writes/page.tsx new file mode 100644 index 0000000..ddcc1e3 --- /dev/null +++ b/app/admin/itglue-writes/page.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +interface WriteRow { + id: string; + audit_id: string | null; + asset_type: 'flexible_asset'; + asset_id: string; + field_name: string; + before_value: unknown; + after_value: unknown; + performed_by_user_id: string | null; + performed_at: string; + status: 'pending' | 'committed' | 'failed' | 'reverted'; + error_message: string | null; +} + +const STATUSES: Array = [ + 'all', + 'committed', + 'reverted', + 'failed', + 'pending', +]; + +function statusVariant( + s: WriteRow['status'] +): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (s) { + case 'committed': + return 'default'; + case 'reverted': + return 'secondary'; + case 'failed': + return 'destructive'; + default: + return 'outline'; + } +} + +export default function ItglueWritesPage() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = + useState('all'); + + async function load(): Promise { + try { + const url = + statusFilter === 'all' + ? '/api/analyzer/itglue/writes' + : `/api/analyzer/itglue/writes?status=${statusFilter}`; + const res = await fetch(url); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as { writes: WriteRow[] }; + setRows(data.writes); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter]); + + return ( +
+ + +
+
+ IT Glue write log +

+ Every PATCH to IT Glue from Pulse, with before/after diffs and + revert history. +

+
+
+ {STATUSES.map((s) => ( + + ))} +
+
+
+ + {error && ( + + Couldn’t load writes + {error} + + )} + {rows === null && !error ? ( +
+ + + +
+ ) : rows && rows.length === 0 ? ( +

No writes recorded yet.

+ ) : ( +
    + {(rows ?? []).map((w) => ( +
  • +
    +
    +

    + + {w.asset_id} + + {' · '} + {w.field_name} +

    +

    + {new Date(w.performed_at).toLocaleString()} +

    +

    + Before: + + {w.before_value === null || w.before_value === undefined + ? '(empty)' + : JSON.stringify(w.before_value).slice(0, 200)} + +

    +

    + After: + + {JSON.stringify(w.after_value).slice(0, 200)} + +

    + {w.error_message && ( +

    + Error: {w.error_message} +

    + )} +
    + {w.status} +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..3d452a1 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + RefreshCw, + Network, + Globe, + Smartphone, + Radio, + AlertTriangle, + Workflow, + GitBranch, + Sparkles, + Zap, + Bell, + Sun, + BarChart3, + ScrollText, + Database, + SlidersHorizontal, + Activity, + Tv, + Users, + ShieldCheck, + Settings as SettingsIcon, + Shield, + DollarSign, + CalendarClock, +} from 'lucide-react'; + +interface AdminCounts { + linkConflicts: number; + schedules: { enabled: number; total: number }; + unmappedAuvik: number; + unmappedRmm: number; + unmappedAddigy: number; +} + +interface NavTile { + title: string; + href: string; + icon: React.ElementType; + description?: string; + badge?: { label: string; tone: 'warn' | 'info' | 'muted' }; +} + +interface Section { + title: string; + tiles: NavTile[]; +} + +function tone(badge?: NavTile['badge']) { + if (!badge) return null; + const variant: 'destructive' | 'secondary' | 'outline' = + badge.tone === 'warn' ? 'destructive' : badge.tone === 'info' ? 'secondary' : 'outline'; + return ( + + {badge.label} + + ); +} + +export default function AdminIndexPage() { + const [counts, setCounts] = useState(null); + + useEffect(() => { + void (async () => { + try { + const [overviewRes, mappingsRes] = await Promise.all([ + fetch('/api/dashboard/overview'), + fetch('/api/dashboard/stats'), + ]); + const overview = overviewRes.ok ? await overviewRes.json() : null; + const mappings = mappingsRes.ok ? await mappingsRes.json() : null; + setCounts({ + linkConflicts: overview?.attention?.linkConflicts ?? 0, + schedules: overview?.attention?.schedules ?? { enabled: 0, total: 0 }, + unmappedAuvik: mappings?.mappings?.auvik?.unmapped ?? 0, + unmappedRmm: mappings?.mappings?.rmm?.unmapped ?? 0, + unmappedAddigy: 0, + }); + } catch { + // Counts are decorative — fail quiet. + } + })(); + }, []); + + const sections: Section[] = [ + { + title: 'Sync', + tiles: [ + { + title: 'Integrations & Sync', + href: '/admin/sync', + icon: RefreshCw, + description: 'Overview of sync status across all integrations', + badge: counts + ? { + label: `${counts.schedules.enabled}/${counts.schedules.total} schedules on`, + tone: 'muted', + } + : undefined, + }, + { title: 'Autotask', href: '/admin/sync/autotask', icon: RefreshCw }, + { title: 'Datto RMM', href: '/admin/sync/datto-rmm', icon: Globe }, + { title: 'IT Glue', href: '/admin/sync/itglue', icon: Shield }, + { title: 'SentinelOne', href: '/admin/sync/sentinelone', icon: Shield }, + { title: 'Veeam', href: '/admin/sync/veeam', icon: Activity }, + { title: 'Auvik', href: '/admin/sync/auvik', icon: Network }, + { title: 'Addigy', href: '/admin/sync/addigy', icon: Smartphone }, + { title: 'Mimecast', href: '/admin/sync/mimecast', icon: Shield }, + { title: 'Duo', href: '/admin/sync/duo', icon: Shield }, + { title: 'QuickBooks Online', href: '/admin/qbo', icon: DollarSign }, + ], + }, + { + title: 'Mappings', + tiles: [ + { + title: 'Device-Link Conflicts', + href: '/admin/device-link-conflicts', + icon: AlertTriangle, + description: 'Resolve cases where one external device matches multiple Autotask CIs', + badge: + counts && counts.linkConflicts > 0 + ? { label: counts.linkConflicts.toLocaleString(), tone: 'warn' } + : undefined, + }, + { + title: 'NMS Mapping (Auvik)', + href: '/auvik-mappings', + icon: Network, + description: 'Map Auvik tenants to companies', + badge: + counts && counts.unmappedAuvik > 0 + ? { label: `${counts.unmappedAuvik} unmapped`, tone: 'info' } + : undefined, + }, + { + title: 'RMM Mapping (Datto)', + href: '/rmm-mappings', + icon: Globe, + description: 'Map RMM sites to companies', + badge: + counts && counts.unmappedRmm > 0 + ? { label: `${counts.unmappedRmm} unmapped`, tone: 'info' } + : undefined, + }, + { + title: 'Apple RMM (Addigy)', + href: '/addigy-mappings', + icon: Smartphone, + description: 'Map Addigy devices to companies', + }, + { + title: 'SentinelOne Mappings', + href: '/sentinelone/mappings', + icon: Shield, + description: 'Map S1 sites to companies (gap blocks reconciler)', + }, + { + title: 'Zabbix WAN Monitor', + href: '/admin/zabbix-wan', + icon: Radio, + description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing', + }, + ], + }, + { + title: 'Workflow', + tiles: [ + { + title: 'Ticket Workflows', + href: '/admin/workflow', + icon: Workflow, + description: 'Automated ticket triage and classification', + }, + { + title: 'Classification Rules', + href: '/admin/workflow/classification-rules', + icon: GitBranch, + description: 'Keyword-based classification rules', + }, + { + title: 'AI Templates', + href: '/admin/workflow/ai-templates', + icon: Sparkles, + description: 'AI prompt templates for enhancement', + }, + { + title: 'Webhook Pipelines', + href: '/admin/workflow/pipelines', + icon: Zap, + description: 'Automated webhook processing workflows', + }, + { + title: 'Notification Channels', + href: '/admin/workflow/channels', + icon: Bell, + description: 'Teams, Telegram, and webhook notifications', + }, + ], + }, + { + title: 'Reports', + tiles: [ + { + title: 'Morning NOC Summary', + href: '/admin/morning-summary', + icon: Sun, + description: 'Daily Zabbix overnight summary posted to Teams', + }, + { + title: 'Ticket Digest Reports', + href: '/admin/ticket-digest', + icon: BarChart3, + description: 'LLM-analyzed ticket reports — daily/weekly/monthly', + }, + { + title: 'IT Glue Writes', + href: '/admin/itglue-writes', + icon: ScrollText, + description: 'History of audit-driven IT Glue field writes', + }, + { + title: 'Audit Log', + href: '/admin/audit-log', + icon: ScrollText, + description: 'System audit trail', + }, + ], + }, + { + title: 'Tools & Data', + tiles: [ + { + title: 'RMM Overshell', + href: '/admin/rmm-overshell', + icon: Database, + description: 'Datto RMM PowerShell discovery — settings, executions, evidence pipeline', + }, + { + title: 'Data Browser', + href: '/admin/data-browser', + icon: Database, + description: 'Browse and query system data', + }, + { + title: 'Display Settings', + href: '/admin/display-settings', + icon: SlidersHorizontal, + description: 'Configure company filters for Kiosk and Mobile dashboards', + }, + { + title: 'Kiosk Settings', + href: '/kiosk/settings', + icon: Tv, + description: 'Configure executive dashboard for TV display', + }, + ], + }, + { + title: 'Access', + tiles: [ + { title: 'Users', href: '/admin/users', icon: Users }, + { title: 'Roles', href: '/admin/roles', icon: ShieldCheck }, + { title: 'Settings', href: '/admin/settings', icon: SettingsIcon }, + ], + }, + ]; + + return ( +
+
+

Admin

+

+ Sync, mappings, workflows, reporting, and tooling. +

+
+ +
+ {sections.map((section) => ( + + + + {section.title} + + + +
+ {section.tiles.map((tile) => ( + + +
+
+ {tile.title} + {tone(tile.badge)} +
+ {tile.description && ( +

+ {tile.description} +

+ )} +
+ + ))} +
+
+
+ ))} +
+
+ ); +} diff --git a/app/admin/rmm-overshell/page.tsx b/app/admin/rmm-overshell/page.tsx new file mode 100644 index 0000000..be09b8a --- /dev/null +++ b/app/admin/rmm-overshell/page.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Loader2, RefreshCw, Terminal } from 'lucide-react'; +import { toast } from 'sonner'; + +interface Settings { + overshellComponentUid: string | null; + overshellComponentName: string | null; + overshellVariableName: string; + discoveredAt: string | null; + logliftComponentUid: string | null; + logliftComponentName: string | null; + logliftDiscoveredAt: string | null; + updatedAt: string; +} + +interface ExecRow { + id: string; + scriptId: string; + jobName: string; + targetHostname: string | null; + status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout'; + exitCode: number | null; + errorMessage: string | null; + performedByUserId: string | null; + queuedAt: string; + completedAt: string | null; +} + +export default function RmmOvershellAdminPage() { + const [settings, setSettings] = useState(null); + const [counts, setCounts] = useState<{ total: string; running: string; failed_24h: string } | null>(null); + const [executions, setExecutions] = useState(null); + const [error, setError] = useState(null); + const [discovering, setDiscovering] = useState(false); + const [discoveringLoglift, setDiscoveringLoglift] = useState(false); + + async function loadAll() { + try { + const [s, e] = await Promise.all([ + fetch('/api/admin/rmm/settings').then((r) => r.json()), + fetch('/api/rmm/executions?limit=50').then((r) => r.json()), + ]); + if (s.error) throw new Error(s.error); + setSettings(s.settings); + setCounts(s.counts); + setExecutions(e.executions ?? []); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void loadAll(); + }, []); + + async function discover() { + setDiscovering(true); + try { + const res = await fetch('/api/admin/rmm/settings/discover', { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed'); + toast.success(`Found component: ${data.discovered?.name ?? 'unknown'}`); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Discovery failed'); + } finally { + setDiscovering(false); + } + } + + async function discoverLoglift() { + setDiscoveringLoglift(true); + try { + const res = await fetch('/api/admin/rmm/settings/discover-loglift', { + method: 'POST', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? data.error ?? 'Discovery failed'); + toast.success(`Found LogLift component: ${data.discovered?.name ?? 'unknown'}`); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Discovery failed'); + } finally { + setDiscoveringLoglift(false); + } + } + + return ( +
+ + + + + RMM Overshell + +

+ Datto RMM PowerShell evidence pipeline. Pulse dispatches scripts via + the configured Overshell component; the worker polls for results + and the audit pipeline pulls them in as live evidence. +

+
+ + {error && ( + + Couldn’t load settings + {error} + + )} + + {settings === null && !error ? ( + + ) : settings ? ( +
+
+

Overshell component

+ {settings.overshellComponentUid ? ( + <> +

+ {settings.overshellComponentName} +

+

+ {settings.overshellComponentUid} +

+ {settings.discoveredAt && ( +

+ discovered {new Date(settings.discoveredAt).toLocaleString()} +

+ )} + + ) : ( +

+ No component cached. Click Discover to scan Datto RMM. +

+ )} +
+
+

Variable name

+

+ {settings.overshellVariableName} +

+

+ Adjust if your component uses a different variable. +

+
+
+

Activity (24h)

+

+ {counts?.total ?? '0'} total · {counts?.running ?? '0'} running · + + {' '} + {counts?.failed_24h ?? '0'} failed + +

+
+
+ +
+
+

LogLift component

+ {settings.logliftComponentUid ? ( + <> +

+ {settings.logliftComponentName} +

+

+ {settings.logliftComponentUid} +

+ {settings.logliftDiscoveredAt && ( +

+ discovered{' '} + {new Date(settings.logliftDiscoveredAt).toLocaleString()} +

+ )} + + ) : ( +

+ No LogLift component cached. Click below to scan Datto RMM + for one named “loglift” or “eventlog”. +

+ )} +
+ +
+
+
+ ) : null} +
+
+ + + + Recent executions + + + {executions === null ? ( + + ) : executions.length === 0 ? ( +

No executions yet.

+ ) : ( +
    + {executions.map((e) => ( +
  • + {e.scriptId} + {e.targetHostname ?? '—'} + + + {e.status} + {e.exitCode !== null ? ` · exit ${e.exitCode}` : ''} + + + + {new Date(e.queuedAt).toLocaleString()} + + + {e.errorMessage && ( + ! + )} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/analysis/[id]/page.tsx b/app/analyzer/analysis/[id]/page.tsx index 18319f9..53dccb0 100644 --- a/app/analyzer/analysis/[id]/page.tsx +++ b/app/analyzer/analysis/[id]/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, use } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { AnalysisView } from '@/components/analyzer/analysis-view'; +import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel'; import type { PersistedAnalysis } from '@/lib/types/analyzer'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -50,7 +51,12 @@ export default function AnalysisDetailPage({ )} - {analysis && } + {analysis && ( +
+ + +
+ )} ); } diff --git a/app/analyzer/itglue/applications/[id]/page.tsx b/app/analyzer/itglue/applications/[id]/page.tsx new file mode 100644 index 0000000..4df64eb --- /dev/null +++ b/app/analyzer/itglue/applications/[id]/page.tsx @@ -0,0 +1,800 @@ +'use client'; + +import { useEffect, useMemo, useState, use } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; +import { + ProviderToggle, + type AnalyzerProvider, +} from '@/components/analyzer/provider-toggle'; +import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker'; +import { toast } from 'sonner'; +import { + Sparkles, + Loader2, + ExternalLink, + AlertTriangle, + ArrowLeftRight, + CheckCircle2, + Undo2, +} from 'lucide-react'; +import { useSession } from '@/lib/auth-client'; + +interface FieldRow { + id: string; + name: string; + kind: string | null; + hint: string | null; + required: boolean; +} + +interface AssetDetail { + asset: { + id: string; + name: string | null; + organizationId: string | null; + organizationName: string | null; + flexibleAssetTypeId: string; + flexibleAssetTypeName: string | null; + autotaskCompanyId: string | null; + traits: Record; + createdAt: string | null; + updatedAt: string | null; + }; + fields: FieldRow[]; +} + +interface FieldGap { + field_name: string; + why_missing_matters: string; + suggested_value: string | null; + evidence_ticket_numbers: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +interface NotePromotion { + quoted_note_text: string; + target_field: string; + suggested_value: string; + confidence: 'high' | 'medium' | 'low'; +} + +interface Contradiction { + description: string; + evidence: string; +} + +interface AuditRow { + id: string; + generated_at: string; + provider: 'anthropic' | 'openrouter'; + model_used: string | null; + ticket_count: number; + field_gaps: FieldGap[]; + notes_promotions: NotePromotion[]; + contradictions: Contradiction[]; + overall_score: number | null; + estimated_cost_usd: number | null; +} + +interface WriteRow { + id: string; + audit_id: string | null; + field_name: string; + before_value: unknown; + after_value: unknown; + performed_by_user_id: string | null; + performed_at: string; + status: 'pending' | 'committed' | 'failed' | 'reverted'; + error_message: string | null; +} + +interface XrefRow { + id: string; + ticketNumber: string; + analysisId: string | null; + relationship: 'referenced' | 'updated' | 'should_have_referenced'; + source: string; + details: { write_id?: string; field_name?: string; relevance_reason?: string } | null; + createdAt: string; +} + +const CONFIDENCE_TONE: Record = { + high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300', + medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300', + low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300', +}; + +function fieldNameToTraitKey(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function formatTraitValue(v: unknown): string { + if (v === null || v === undefined) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + if (Array.isArray(v)) return v.length === 0 ? '' : JSON.stringify(v); + if (typeof v === 'object') { + const obj = v as { values?: unknown[] }; + if (Array.isArray(obj.values)) { + return obj.values + .map((it) => { + const o = it as { name?: string; 'first-name'?: string; 'last-name'?: string }; + if (o.name) return o.name; + if (o['first-name'] || o['last-name']) + return [o['first-name'], o['last-name']].filter(Boolean).join(' '); + return JSON.stringify(it); + }) + .join(', '); + } + return JSON.stringify(v).slice(0, 200); + } + return String(v); +} + +function isPopulated(v: unknown): boolean { + if (v === null || v === undefined) return false; + if (typeof v === 'string') return v.trim().length > 0; + if (Array.isArray(v)) return v.length > 0; + if (typeof v === 'object') { + const obj = v as { values?: unknown[] }; + if (Array.isArray(obj.values)) return obj.values.length > 0; + return Object.keys(v).length > 0; + } + return true; +} + +export default function ApplicationAuditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { data: session } = useSession(); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canWrite = role === 'admin' || role === 'super-admin'; + + const [detail, setDetail] = useState(null); + const [audit, setAudit] = useState(null); + const [history, setHistory] = useState([]); + const [writes, setWrites] = useState([]); + const [xrefs, setXrefs] = useState([]); + const [error, setError] = useState(null); + const [provider, setProvider] = useState('anthropic'); + const [running, setRunning] = useState(false); + const [busyKey, setBusyKey] = useState(null); + + async function loadAll(): Promise { + try { + const [d, a, w, x] = await Promise.all([ + fetch(`/api/analyzer/itglue/applications/${id}`).then((r) => r.json()), + fetch(`/api/analyzer/itglue/applications/${id}/audit?history=1`).then( + (r) => r.json() + ), + fetch(`/api/analyzer/itglue/applications/${id}/writes`).then((r) => + r.json() + ), + fetch(`/api/analyzer/itglue/applications/${id}/xrefs`).then((r) => + r.json() + ), + ]); + if (d.error) throw new Error(d.error); + setDetail(d as AssetDetail); + setAudit(a.audit ?? null); + setHistory(a.history ?? []); + setWrites(w.writes ?? []); + setXrefs(x.xrefs ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + async function runAudit(): Promise { + setRunning(true); + try { + const res = await fetch(`/api/analyzer/itglue/applications/${id}/audit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error || 'Audit failed'); + setAudit(data.audit); + // Refresh history. + void loadAll(); + toast.success('Audit complete'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Audit failed'); + } finally { + setRunning(false); + } + } + + async function applyGap( + gap: FieldGap | NotePromotion, + kind: 'field_gap' | 'note_promotion' + ): Promise { + if (!canWrite) return; + if (!audit) return; + const fieldName = + kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field; + const suggested = + kind === 'field_gap' + ? (gap as FieldGap).suggested_value + : (gap as NotePromotion).suggested_value; + if (suggested === null || suggested === undefined || suggested === '') { + toast.error('No suggested value to apply'); + return; + } + const evidence = + kind === 'field_gap' + ? { + ticket_numbers: (gap as FieldGap).evidence_ticket_numbers, + gap_description: (gap as FieldGap).why_missing_matters, + } + : { + ticket_numbers: [], + gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`, + }; + const key = `${kind}:${fieldName}`; + setBusyKey(key); + try { + const res = await fetch(`/api/analyzer/itglue/applications/${id}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auditId: audit.id, + fieldName, + suggestedValue: suggested, + sourceEvidence: evidence, + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || data.error || 'Apply failed'); + } + toast.success(`Applied: ${fieldName}`); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Apply failed'); + } finally { + setBusyKey(null); + } + } + + async function revertWrite(writeId: string): Promise { + if (!canWrite) return; + setBusyKey(`revert:${writeId}`); + try { + const res = await fetch( + `/api/analyzer/itglue/applications/${id}/revert/${writeId}`, + { method: 'POST' } + ); + const data = await res.json(); + if (!res.ok) + throw new Error(data.message || data.error || 'Revert failed'); + toast.success('Reverted'); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Revert failed'); + } finally { + setBusyKey(null); + } + } + + const orderedFields = useMemo(() => { + if (!detail) return []; + return detail.fields.map((f) => { + const traitKey = fieldNameToTraitKey(f.name); + const value = detail.asset.traits[traitKey]; + return { ...f, traitKey, value, populated: isPopulated(value) }; + }); + }, [detail]); + + if (error) { + return ( +
+ + Couldn’t load this asset + {error} + +
+ ); + } + if (!detail) { + return ( +
+ + +
+ ); + } + + const a = detail.asset; + const filledCount = orderedFields.filter((f) => f.populated).length; + const totalCount = orderedFields.length; + + return ( +
+ {/* Header */} + + +
+
+

+ + Applications + {' '} + · {a.organizationName ?? 'Unknown org'} +

+ {a.name ?? a.id} +

+ {filledCount}/{totalCount} fields populated + {audit?.overall_score !== null && audit?.overall_score !== undefined ? ( + <> + {' · '} + 0.8 + ? 'default' + : (audit.overall_score ?? 0) > 0.5 + ? 'secondary' + : 'destructive' + } + > + Audit score {Math.round((audit.overall_score ?? 0) * 100)}% + + + ) : null} +

+
+
+ + + {a.autotaskCompanyId && ( + loadAll()} + /> + )} + +
+
+
+
+ + {/* Audit findings */} + {audit && ( + + + + Audit findings + + {new Date(audit.generated_at).toLocaleString()} + {' · '} + {audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'} + {audit.estimated_cost_usd !== null + ? ` · $${audit.estimated_cost_usd.toFixed(4)}` + : ''} + {' · '} + {audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'} + + + + + {/* Field gaps */} +
+

+ Field gaps ({audit.field_gaps.length}) +

+ {audit.field_gaps.length === 0 ? ( +

No field gaps detected.

+ ) : ( +
    + {audit.field_gaps.map((g) => { + const key = `field_gap:${g.field_name}`; + const busy = busyKey === key; + return ( +
  • +
    +
    +

    {g.field_name}

    +

    {g.why_missing_matters}

    + {g.suggested_value !== null && ( +

    + Suggested: + {g.suggested_value} +

    + )} + {g.evidence_ticket_numbers.length > 0 && ( +

    + Evidence:{' '} + {g.evidence_ticket_numbers.map((tn, i) => ( + + {i > 0 && ', '} + + {tn} + + + ))} +

    + )} +
    +
    + + {g.confidence} + + +
    +
    +
  • + ); + })} +
+ )} +
+ + {/* Notes promotions */} + {audit.notes_promotions.length > 0 && ( +
+

+ Promote from Notes ({audit.notes_promotions.length}) +

+
    + {audit.notes_promotions.map((p, i) => { + const key = `note_promotion:${p.target_field}:${i}`; + const busy = busyKey === `note_promotion:${p.target_field}`; + return ( +
  • +
    +
    +

    + “{p.quoted_note_text}” +

    +

    + Belongs in{' '} + {p.target_field} + :{' '} + {p.suggested_value} +

    +
    +
    + + {p.confidence} + + +
    +
    +
  • + ); + })} +
+
+ )} + + {/* Contradictions */} + {audit.contradictions.length > 0 && ( +
+

+ Contradictions ({audit.contradictions.length}) +

+
    + {audit.contradictions.map((c, i) => ( +
  • + +
    +

    {c.description}

    +

    + {c.evidence} +

    +
    +
  • + ))} +
+
+ )} +
+
+ )} + + {!audit && ( + + + No audit yet. Click Run audit to analyze this asset. + + + )} + + {/* Current fields */} + + + Current fields + + +
+ {orderedFields.map((f) => ( +
+
+ {f.name} + {f.required && *} +
+
+ {f.populated ? ( + formatTraitValue(f.value) + ) : ( + empty + )} + {f.hint && !f.populated && ( +

{f.hint}

+ )} +
+
+ ))} +
+
+
+ + {/* Tickets that touched this asset */} + {(xrefs.filter((x) => x.relationship === 'referenced').length > 0 || + xrefs.filter((x) => x.relationship === 'updated').length > 0) && ( + + + Tickets that touched this asset + + + {xrefs.filter((x) => x.relationship === 'referenced').length > 0 && ( +
+

+ Referenced by ({xrefs.filter((x) => x.relationship === 'referenced').length}) +

+
    + {xrefs + .filter((x) => x.relationship === 'referenced') + .map((x) => ( +
  • + + {x.ticketNumber} + + {x.details?.relevance_reason && ( + + — {x.details.relevance_reason} + + )} +
  • + ))} +
+
+ )} + {xrefs.filter((x) => x.relationship === 'updated').length > 0 && ( +
+

+ Updated by ({xrefs.filter((x) => x.relationship === 'updated').length}) +

+
    + {xrefs + .filter((x) => x.relationship === 'updated') + .map((x) => ( +
  • + + {x.ticketNumber} + + {x.details?.field_name && ( + + — set {x.details.field_name} + + )} +
  • + ))} +
+
+ )} +
+
+ )} + + {/* Write history */} + {writes.length > 0 && ( + + + + + Write history ({writes.length}) + + + +
    + {writes.map((w) => ( +
  • +
    +

    + {w.field_name} + + {w.status} + +

    +

    + {new Date(w.performed_at).toLocaleString()} +

    +

    + Before: + + {w.before_value === null || w.before_value === undefined + ? '(empty)' + : JSON.stringify(w.before_value).slice(0, 200)} + +

    +

    + After: + + {JSON.stringify(w.after_value).slice(0, 200)} + +

    + {w.error_message && ( +

    + Error: {w.error_message} +

    + )} +
    + {w.status === 'committed' && ( + + )} +
  • + ))} +
+
+
+ )} + + {/* Audit history */} + {history.length > 1 && ( + + + Audit history + + +
    + {history.map((h) => ( +
  • + + {new Date(h.generated_at).toLocaleString()} + {' · '} + {h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'} + + + Score{' '} + + {h.overall_score !== null + ? Math.round(h.overall_score * 100) + '%' + : 'n/a'} + + +
  • + ))} +
+
+ +
+ )} +
+ ); +} diff --git a/app/analyzer/itglue/applications/page.tsx b/app/analyzer/itglue/applications/page.tsx new file mode 100644 index 0000000..08ac8f4 --- /dev/null +++ b/app/analyzer/itglue/applications/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Input } from '@/components/ui/input'; + +interface ApplicationRow { + id: string; + name: string | null; + organizationId: string | null; + organizationName: string | null; + traitCount: number; + latestAudit: { + id: string; + generatedAt: string | null; + overallScore: number | null; + provider: 'anthropic' | 'openrouter' | null; + } | null; +} + +function scoreBadgeVariant( + score: number | null +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (score === null) return 'outline'; + if (score > 0.8) return 'default'; + if (score > 0.5) return 'secondary'; + return 'destructive'; +} + +export default function ApplicationsListPage() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/analyzer/itglue/applications'); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + const data = (await res.json()) as { applications: ApplicationRow[] }; + if (!cancelled) setRows(data.applications); + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const visible = rows + ? rows.filter((r) => { + if (!filter.trim()) return true; + const q = filter.toLowerCase(); + return ( + (r.name ?? '').toLowerCase().includes(q) || + (r.organizationName ?? '').toLowerCase().includes(q) + ); + }) + : []; + + const auditedCount = rows + ? rows.filter((r) => r.latestAudit !== null).length + : 0; + + return ( +
+ + +
+
+ IT Glue applications audit +

+ {rows === null + ? 'Loading…' + : `${rows.length} application records · ${auditedCount} audited`} +

+
+ setFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + {error && ( + + Couldn’t load applications + {error} + + )} + {rows === null && !error ? ( +
+ + + +
+ ) : ( +
    + {visible.map((r) => ( +
  • +
    + + {r.name ?? r.id} + +

    + {r.organizationName ?? '—'} + {' · '} + {r.traitCount} field{r.traitCount === 1 ? '' : 's'} populated + {r.latestAudit && ( + <> + {' · '} + last audited{' '} + {r.latestAudit.generatedAt + ? new Date(r.latestAudit.generatedAt).toLocaleDateString() + : 'unknown'} + {' '} + ( + {r.latestAudit.provider === 'openrouter' + ? 'DeepSeek' + : 'Claude'} + ) + + )} +

    +
    + + {r.latestAudit?.overallScore !== null && + r.latestAudit?.overallScore !== undefined + ? Math.round(r.latestAudit.overallScore * 100) + '%' + : 'No audit'} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/itglue/configurations/[id]/page.tsx b/app/analyzer/itglue/configurations/[id]/page.tsx new file mode 100644 index 0000000..55c7ee1 --- /dev/null +++ b/app/analyzer/itglue/configurations/[id]/page.tsx @@ -0,0 +1,762 @@ +'use client'; + +import { useEffect, useMemo, useState, use } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; +import { + ProviderToggle, + type AnalyzerProvider, +} from '@/components/analyzer/provider-toggle'; +import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker'; +import { toast } from 'sonner'; +import { + Sparkles, + Loader2, + ExternalLink, + AlertTriangle, + ArrowLeftRight, + CheckCircle2, + Undo2, + Server, +} from 'lucide-react'; +import { useSession } from '@/lib/auth-client'; + +interface FieldRow { + id: string; + name: string; + kind: string | null; + hint: string | null; + required: boolean; +} + +interface AssetDetail { + asset: { + id: string; + name: string; + hostname: string | null; + organizationId: string | null; + organizationName: string | null; + typeId: string | null; + typeName: string | null; + statusName: string | null; + operatingSystemName: string | null; + dattoDeviceUid: string | null; + autotaskCompanyId: string | null; + traits: Record; + createdAt: string | null; + updatedAt: string | null; + }; + fields: FieldRow[]; +} + +interface FieldGap { + field_name: string; + why_missing_matters: string; + suggested_value: string | null; + evidence_ticket_numbers: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +interface NotePromotion { + quoted_note_text: string; + target_field: string; + suggested_value: string; + confidence: 'high' | 'medium' | 'low'; +} + +interface Contradiction { + description: string; + evidence: string; +} + +interface AuditRow { + id: string; + generated_at: string; + provider: 'anthropic' | 'openrouter'; + model_used: string | null; + ticket_count: number; + field_gaps: FieldGap[]; + notes_promotions: NotePromotion[]; + contradictions: Contradiction[]; + overall_score: number | null; + estimated_cost_usd: number | null; +} + +interface WriteRow { + id: string; + audit_id: string | null; + field_name: string; + before_value: unknown; + after_value: unknown; + performed_by_user_id: string | null; + performed_at: string; + status: 'pending' | 'committed' | 'failed' | 'reverted'; + error_message: string | null; +} + +interface XrefRow { + id: string; + ticketNumber: string; + analysisId: string | null; + relationship: 'referenced' | 'updated' | 'should_have_referenced'; + source: string; + details: { write_id?: string; field_name?: string; relevance_reason?: string } | null; + createdAt: string; +} + +const CONFIDENCE_TONE: Record = { + high: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-300', + medium: 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300', + low: 'border-blue-500 bg-blue-500/10 text-blue-700 dark:text-blue-300', +}; + +function isPopulated(v: unknown): boolean { + if (v === null || v === undefined) return false; + if (typeof v === 'string') return v.trim().length > 0; + if (Array.isArray(v)) return v.length > 0; + if (typeof v === 'object') return Object.keys(v).length > 0; + return true; +} + +function formatValue(v: unknown): string { + if (v === null || v === undefined) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return JSON.stringify(v).slice(0, 200); +} + +export default function ConfigurationAuditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { data: session } = useSession(); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canWrite = role === 'admin' || role === 'super-admin'; + + const [detail, setDetail] = useState(null); + const [audit, setAudit] = useState(null); + const [history, setHistory] = useState([]); + const [writes, setWrites] = useState([]); + const [xrefs, setXrefs] = useState([]); + const [error, setError] = useState(null); + const [provider, setProvider] = useState('anthropic'); + const [running, setRunning] = useState(false); + const [busyKey, setBusyKey] = useState(null); + + async function loadAll(): Promise { + try { + const [d, a, w, x] = await Promise.all([ + fetch(`/api/analyzer/itglue/configurations/${id}`).then((r) => r.json()), + fetch(`/api/analyzer/itglue/configurations/${id}/audit?history=1`).then( + (r) => r.json() + ), + fetch(`/api/analyzer/itglue/configurations/${id}/writes`).then((r) => + r.json() + ), + fetch(`/api/analyzer/itglue/configurations/${id}/xrefs`).then((r) => + r.json() + ), + ]); + if (d.error) throw new Error(d.error); + setDetail(d as AssetDetail); + setAudit(a.audit ?? null); + setHistory(a.history ?? []); + setWrites(w.writes ?? []); + setXrefs(x.xrefs ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + async function runAudit(): Promise { + setRunning(true); + try { + const res = await fetch(`/api/analyzer/itglue/configurations/${id}/audit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error || 'Audit failed'); + setAudit(data.audit); + void loadAll(); + toast.success('Audit complete'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Audit failed'); + } finally { + setRunning(false); + } + } + + async function applyGap( + gap: FieldGap | NotePromotion, + kind: 'field_gap' | 'note_promotion' + ): Promise { + if (!canWrite || !audit) return; + const fieldName = + kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field; + const suggested = + kind === 'field_gap' + ? (gap as FieldGap).suggested_value + : (gap as NotePromotion).suggested_value; + if (suggested === null || suggested === undefined || suggested === '') { + toast.error('No suggested value to apply'); + return; + } + const evidence = + kind === 'field_gap' + ? { + ticket_numbers: (gap as FieldGap).evidence_ticket_numbers, + gap_description: (gap as FieldGap).why_missing_matters, + } + : { + ticket_numbers: [], + gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`, + }; + const key = `${kind}:${fieldName}`; + setBusyKey(key); + try { + const res = await fetch(`/api/analyzer/itglue/configurations/${id}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auditId: audit.id, + fieldName, + suggestedValue: suggested, + sourceEvidence: evidence, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error || 'Apply failed'); + toast.success(`Applied: ${fieldName}`); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Apply failed'); + } finally { + setBusyKey(null); + } + } + + async function revertWrite(writeId: string): Promise { + if (!canWrite) return; + setBusyKey(`revert:${writeId}`); + try { + const res = await fetch( + `/api/analyzer/itglue/configurations/${id}/revert/${writeId}`, + { method: 'POST' } + ); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || data.error || 'Revert failed'); + toast.success('Reverted'); + void loadAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Revert failed'); + } finally { + setBusyKey(null); + } + } + + const orderedFields = useMemo(() => { + if (!detail) return []; + return detail.fields.map((f) => { + const value = detail.asset.traits[f.name]; + return { ...f, value, populated: isPopulated(value) }; + }); + }, [detail]); + + if (error) { + return ( +
+ + Couldn’t load this configuration + {error} + +
+ ); + } + if (!detail) { + return ( +
+ + +
+ ); + } + + const a = detail.asset; + const filledCount = orderedFields.filter((f) => f.populated).length; + const totalCount = orderedFields.length; + const referencedXrefs = xrefs.filter((x) => x.relationship === 'referenced'); + const updatedXrefs = xrefs.filter((x) => x.relationship === 'updated'); + + return ( +
+ + +
+
+

+ + Configurations + {' '} + · {a.organizationName ?? 'Unknown org'} + {a.typeName && ` · ${a.typeName}`} +

+ + + {a.name} + +

+ {filledCount}/{totalCount} fields populated + {a.statusName && ` · ${a.statusName}`} + {a.operatingSystemName && ` · ${a.operatingSystemName}`} + {audit?.overall_score !== null && audit?.overall_score !== undefined && ( + <> + {' · '} + 0.8 + ? 'default' + : (audit.overall_score ?? 0) > 0.5 + ? 'secondary' + : 'destructive' + } + > + Audit score {Math.round((audit.overall_score ?? 0) * 100)}% + + + )} +

+
+
+ + + {a.dattoDeviceUid && ( + loadAll()} + /> + )} + +
+
+
+
+ + {audit && ( + + + + Audit findings + + {new Date(audit.generated_at).toLocaleString()} + {' · '} + {audit.provider === 'openrouter' ? 'DeepSeek' : 'Claude'} + {audit.estimated_cost_usd !== null + ? ` · $${audit.estimated_cost_usd.toFixed(4)}` + : ''} + {' · '} + {audit.ticket_count} ticket{audit.ticket_count === 1 ? '' : 's'} + + + + +
+

+ Field gaps ({audit.field_gaps.length}) +

+ {audit.field_gaps.length === 0 ? ( +

No field gaps detected.

+ ) : ( +
    + {audit.field_gaps.map((g) => { + const key = `field_gap:${g.field_name}`; + const busy = busyKey === key; + return ( +
  • +
    +
    +

    {g.field_name}

    +

    {g.why_missing_matters}

    + {g.suggested_value !== null && ( +

    + Suggested: + {g.suggested_value} +

    + )} + {g.evidence_ticket_numbers.length > 0 && ( +

    + Evidence:{' '} + {g.evidence_ticket_numbers.map((tn, i) => ( + + {i > 0 && ', '} + + {tn} + + + ))} +

    + )} +
    +
    + + {g.confidence} + + +
    +
    +
  • + ); + })} +
+ )} +
+ + {audit.notes_promotions.length > 0 && ( +
+

+ Promote from Notes ({audit.notes_promotions.length}) +

+
    + {audit.notes_promotions.map((p, i) => { + const busy = busyKey === `note_promotion:${p.target_field}`; + return ( +
  • +
    +
    +

    + “{p.quoted_note_text}” +

    +

    + Belongs in{' '} + {p.target_field} + :{' '} + {p.suggested_value} +

    +
    +
    + + {p.confidence} + + +
    +
    +
  • + ); + })} +
+
+ )} + + {audit.contradictions.length > 0 && ( +
+

+ Contradictions ({audit.contradictions.length}) +

+
    + {audit.contradictions.map((c, i) => ( +
  • + +
    +

    {c.description}

    +

    {c.evidence}

    +
    +
  • + ))} +
+
+ )} +
+
+ )} + + {!audit && ( + + + No audit yet. Click Run audit to analyze this configuration. + + + )} + + + + Current fields + + +
+ {orderedFields.map((f) => ( +
+
+ {f.name} + {f.required && *} +
+
+ {f.populated ? ( + formatValue(f.value) + ) : ( + empty + )} + {f.hint && !f.populated && ( +

{f.hint}

+ )} +
+
+ ))} +
+
+
+ + {(referencedXrefs.length > 0 || updatedXrefs.length > 0) && ( + + + Tickets that touched this configuration + + + {referencedXrefs.length > 0 && ( +
+

+ Referenced by ({referencedXrefs.length}) +

+
    + {referencedXrefs.map((x) => ( +
  • + + {x.ticketNumber} + + {x.details?.relevance_reason && ( + + — {x.details.relevance_reason} + + )} +
  • + ))} +
+
+ )} + {updatedXrefs.length > 0 && ( +
+

+ Updated by ({updatedXrefs.length}) +

+
    + {updatedXrefs.map((x) => ( +
  • + + {x.ticketNumber} + + {x.details?.field_name && ( + + — set {x.details.field_name} + + )} +
  • + ))} +
+
+ )} +
+
+ )} + + {writes.length > 0 && ( + + + + + Write history ({writes.length}) + + + +
    + {writes.map((w) => ( +
  • +
    +

    + {w.field_name} + + {w.status} + +

    +

    + {new Date(w.performed_at).toLocaleString()} +

    +

    + Before: + + {w.before_value === null || w.before_value === undefined + ? '(empty)' + : JSON.stringify(w.before_value).slice(0, 200)} + +

    +

    + After: + + {JSON.stringify(w.after_value).slice(0, 200)} + +

    + {w.error_message && ( +

    Error: {w.error_message}

    + )} +
    + {w.status === 'committed' && ( + + )} +
  • + ))} +
+
+
+ )} + + {history.length > 1 && ( + + + Audit history + + +
    + {history.map((h) => ( +
  • + + {new Date(h.generated_at).toLocaleString()} + {' · '} + {h.provider === 'openrouter' ? 'DeepSeek' : 'Claude'} + + + Score{' '} + + {h.overall_score !== null + ? Math.round(h.overall_score * 100) + '%' + : 'n/a'} + + +
  • + ))} +
+
+ +
+ )} +
+ ); +} diff --git a/app/analyzer/itglue/configurations/page.tsx b/app/analyzer/itglue/configurations/page.tsx new file mode 100644 index 0000000..f793e27 --- /dev/null +++ b/app/analyzer/itglue/configurations/page.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Input } from '@/components/ui/input'; + +interface ConfigurationRow { + id: string; + name: string; + hostname: string | null; + typeName: string | null; + statusName: string | null; + organizationId: string | null; + organizationName: string | null; + latestAudit: { + id: string; + generatedAt: string | null; + overallScore: number | null; + provider: 'anthropic' | 'openrouter' | null; + } | null; +} + +function scoreBadgeVariant( + score: number | null +): 'default' | 'secondary' | 'destructive' | 'outline' { + if (score === null) return 'outline'; + if (score > 0.8) return 'default'; + if (score > 0.5) return 'secondary'; + return 'destructive'; +} + +export default function ConfigurationsListPage() { + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/analyzer/itglue/configurations'); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + const data = (await res.json()) as { configurations: ConfigurationRow[] }; + if (!cancelled) setRows(data.configurations); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const visible = rows + ? rows.filter((r) => { + if (!filter.trim()) return true; + const q = filter.toLowerCase(); + return ( + (r.name ?? '').toLowerCase().includes(q) || + (r.hostname ?? '').toLowerCase().includes(q) || + (r.organizationName ?? '').toLowerCase().includes(q) || + (r.typeName ?? '').toLowerCase().includes(q) + ); + }) + : []; + + const auditedCount = rows ? rows.filter((r) => r.latestAudit !== null).length : 0; + + return ( +
+ + +
+
+ IT Glue configurations audit +

+ {rows === null + ? 'Loading…' + : `${rows.length} configurations · ${auditedCount} audited`} +

+
+ setFilter(e.target.value)} + className="max-w-sm" + /> +
+
+ + {error && ( + + Couldn’t load configurations + {error} + + )} + {rows === null && !error ? ( +
+ + + +
+ ) : ( +
    + {visible.map((r) => ( +
  • +
    + + {r.name} + +

    + {r.organizationName ?? '—'} + {r.typeName && ` · ${r.typeName}`} + {r.statusName && ` · ${r.statusName}`} + {r.hostname && ` · ${r.hostname}`} + {r.latestAudit && ( + <> + {' · '}last audited{' '} + {r.latestAudit.generatedAt + ? new Date(r.latestAudit.generatedAt).toLocaleDateString() + : 'unknown'} + {' '} + ( + {r.latestAudit.provider === 'openrouter' + ? 'DeepSeek' + : 'Claude'} + ) + + )} +

    +
    + + {r.latestAudit?.overallScore !== null && + r.latestAudit?.overallScore !== undefined + ? Math.round(r.latestAudit.overallScore * 100) + '%' + : 'No audit'} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/itglue/sites/[companyId]/page.tsx b/app/analyzer/itglue/sites/[companyId]/page.tsx new file mode 100644 index 0000000..0b6c98b --- /dev/null +++ b/app/analyzer/itglue/sites/[companyId]/page.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useEffect, useMemo, useState, use } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { RmmScriptPicker } from '@/components/rmm/rmm-script-picker'; +import { Server } from 'lucide-react'; + +interface SiteInfo { + companyId: string; + companyName: string | null; + itglueOrgId: string | null; + itglueOrgName: string | null; + dattoSiteId: number | null; + wnpHostname: string | null; + wnpDeviceUid: string | null; + wnpOnline: boolean | null; + sites: Array<{ + device_uid: string; + hostname: string | null; + online: boolean; + site_id: number; + site_name: string; + site_device_count: number; + }>; +} + +interface ExecRow { + id: string; + scriptId: string; + jobName: string; + targetHostname: string | null; + status: 'queued' | 'running' | 'complete' | 'failed' | 'timeout'; + exitCode: number | null; + parsedEvidence: unknown; + queuedAt: string; + completedAt: string | null; +} + +export default function SiteDiscoveryPage({ + params, +}: { + params: Promise<{ companyId: string }>; +}) { + const { companyId } = use(params); + const [info, setInfo] = useState(null); + const [executions, setExecutions] = useState(null); + const [error, setError] = useState(null); + + async function loadAll(): Promise { + try { + const [s, e] = await Promise.all([ + fetch(`/api/analyzer/itglue/sites/${companyId}`).then((r) => r.json()), + fetch(`/api/rmm/executions?companyId=${companyId}&limit=50`).then((r) => + r.json() + ), + ]); + if (s.error) throw new Error(s.error); + setInfo(s.site); + setExecutions(e.executions ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + useEffect(() => { + void loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [companyId]); + + const recent = useMemo(() => executions ?? [], [executions]); + + if (error) { + return ( +
+ + Couldn’t load this site + {error} + +
+ ); + } + + if (!info) { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+
+

Site discovery

+ + {info.companyName ?? `Company ${info.companyId}`} + +

+ {info.wnpHostname ? ( + <> + + Primary target:{' '} + {info.wnpHostname} + {info.wnpOnline === false && ( + + offline + + )} + {info.sites.length > 1 && ( + + ({info.sites.length} WNP endpoints across this client’s sites) + + )} + + ) : ( + + No Wulf Nurse Production endpoint registered for this client. + + )} +

+
+ loadAll()} + /> +
+
+ +

+ Site-anchored discovery scripts run from the Wulf Nurse Production + endpoint and use native PowerShell + AD/DHCP/DNS cmdlets to gather + facts about the environment. Output is captured and made available + to subsequent IT Glue audits as live evidence. +

+ {info.sites.length > 1 && ( +
+

+ All sites for this client +

+
    + {info.sites.map((s) => ( +
  • + + {s.hostname} + + {s.site_name} · {s.site_device_count} devices + + {s.hostname === info.wnpHostname && ( + + primary + + )} + + {!s.online && ( + + offline + + )} +
  • + ))} +
+

+ v1 dispatches site-anchored scripts to the primary target only. + A future version will let you pick a specific site here. +

+
+ )} +
+
+ + + + Recent runs ({recent.length}) + + + {recent.length === 0 ? ( +

No runs yet.

+ ) : ( +
    + {recent.map((e) => ( +
  • +
    +
    +

    + {e.scriptId}{' '} + + {e.status} + +

    +

    + {e.targetHostname ?? '—'} ·{' '} + {new Date(e.queuedAt).toLocaleString()} +

    +
    +
    + {e.parsedEvidence !== null && e.parsedEvidence !== undefined && ( +
    + + Show parsed evidence + +
    +                        {JSON.stringify(e.parsedEvidence, null, 2)}
    +                      
    +
    + )} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/ticket/[ticketNumber]/page.tsx b/app/analyzer/ticket/[ticketNumber]/page.tsx index f3cfec9..864ae2d 100644 --- a/app/analyzer/ticket/[ticketNumber]/page.tsx +++ b/app/analyzer/ticket/[ticketNumber]/page.tsx @@ -7,7 +7,12 @@ import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AnalyzeButton } from '@/components/analyzer/analyze-button'; -import { Sparkles } from 'lucide-react'; +import { RelatedTicketsPanel } from '@/components/analyzer/related-tickets-panel'; +import { + ProviderToggle, + type AnalyzerProvider, +} from '@/components/analyzer/provider-toggle'; +import { Sparkles, Zap } from 'lucide-react'; import type { PersistedAnalysis } from '@/lib/types/analyzer'; export default function TicketAnalyzerPage({ @@ -18,6 +23,7 @@ export default function TicketAnalyzerPage({ const { ticketNumber } = use(params); const [analyses, setAnalyses] = useState(null); const [error, setError] = useState(null); + const [provider, setProvider] = useState('anthropic'); useEffect(() => { let cancelled = false; @@ -52,14 +58,18 @@ export default function TicketAnalyzerPage({

Ticket

{ticketNumber} - +
+ + +

- Click Analyze to run the AI pipeline. If a current - analysis already exists, you’ll be navigated straight to it. - Otherwise the run takes ~10–60 seconds. + Click Analyze to run the AI pipeline using the + selected provider. Each provider keeps its own analysis history, + so you can compare Claude and DeepSeek output side-by-side. A run + with the same content hash on the same provider returns instantly.

@@ -71,6 +81,9 @@ export default function TicketAnalyzerPage({ )} + + + Analysis history @@ -87,40 +100,64 @@ export default function TicketAnalyzerPage({

) : (
    - {(analyses ?? []).map((a) => ( -
  • -
    - - - Version {a.analysisVersion} - {latest?.id === a.id && ( - - latest + {(analyses ?? []).map((a) => { + const isOpenRouter = a.provider === 'openrouter'; + const tierLabel = isOpenRouter + ? a.opusUsed + ? 'V4 Flash → V4 Pro → R1' + : a.sonnetUsed + ? 'V4 Flash → V4 Pro' + : 'V4 Flash' + : a.opusUsed + ? 'Haiku → Sonnet → Opus' + : a.sonnetUsed + ? 'Haiku → Sonnet' + : 'Haiku'; + const ProviderIcon = isOpenRouter ? Zap : Sparkles; + return ( +
  • +
    + + + Version {a.analysisVersion} + + {isOpenRouter ? 'DeepSeek' : 'Claude'} - )} - {a.needsHumanReview && ( - - Needs review - - )} - -

    - {new Date(a.triggeredAt).toLocaleString()} - {' · '} - {a.opusUsed ? 'Haiku → Sonnet → Opus' : a.sonnetUsed ? 'Haiku → Sonnet' : 'Haiku'} - {' · '}${a.estimatedCostUsd.toFixed(4)} -

    -
    - {a.confidenceScore !== null && ( - - {Math.round(a.confidenceScore * 100)}% - - )} -
  • - ))} + {latest?.id === a.id && ( + + latest + + )} + {a.needsHumanReview && ( + + Needs review + + )} + +

    + {new Date(a.triggeredAt).toLocaleString()} + {' · '} + {tierLabel} + {' · '}${a.estimatedCostUsd.toFixed(4)} +

    + + {a.confidenceScore !== null && ( + + {Math.round(a.confidenceScore * 100)}% + + )} + + ); + })}
)} diff --git a/app/api/admin/device-link-conflicts/[id]/resolve/route.ts b/app/api/admin/device-link-conflicts/[id]/resolve/route.ts new file mode 100644 index 0000000..9e1900c --- /dev/null +++ b/app/api/admin/device-link-conflicts/[id]/resolve/route.ts @@ -0,0 +1,98 @@ +/** + * POST /api/admin/device-link-conflicts/[id]/resolve + * Body: { ciId: string, note?: string } + * + * Resolves a conflict by manually picking a configuration_item to link the + * underlying device_external_ids row to. Sets link_confidence='manual' and + * marks the review row resolved. + * + * Validates that ciId is in candidate_ci_ids — admins can't pick an arbitrary + * CI here. (For an arbitrary-CI override, separate flow.) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +const ResolveBody = z.object({ + ciId: z.string().regex(/^\d+$/, 'ciId must be numeric'), + note: z.string().max(500).optional(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('admin', 'access'); + if (error) return error; + + const { id } = await params; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return NextResponse.json({ error: 'Invalid review id' }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + const parsed = ResolveBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const ciIdNum = Number(parsed.data.ciId); + + return postgresClient.transaction(async (tx) => { + const reviewRes = await tx.query<{ + device_external_id: string; + candidate_ci_ids: string[]; + resolved_at: string | null; + }>( + `SELECT device_external_id::text, candidate_ci_ids::text[], resolved_at::text + FROM device_link_review + WHERE id = $1 + FOR UPDATE`, + [id] + ); + if (reviewRes.rowCount === 0) { + return NextResponse.json({ error: 'Review not found' }, { status: 404 }); + } + const review = reviewRes.rows[0]; + if (review.resolved_at) { + return NextResponse.json({ error: 'Already resolved' }, { status: 409 }); + } + if (!review.candidate_ci_ids.map(String).includes(String(ciIdNum))) { + return NextResponse.json( + { error: 'ciId must be one of the conflict candidates' }, + { status: 400 } + ); + } + + await tx.query( + `UPDATE device_external_ids + SET configuration_item_id = $2, + link_confidence = 'manual', + linked_at = NOW() + WHERE id = $1`, + [review.device_external_id, ciIdNum] + ); + + await tx.query( + `UPDATE device_link_review + SET resolved_at = NOW(), + resolved_by_user_id = $2, + resolved_to_ci_id = $3, + resolution_note = $4 + WHERE id = $1`, + [id, session?.user?.id ?? null, ciIdNum, parsed.data.note ?? null] + ); + + return NextResponse.json({ ok: true, resolvedToCiId: String(ciIdNum) }); + }); +} diff --git a/app/api/admin/device-link-conflicts/route.ts b/app/api/admin/device-link-conflicts/route.ts new file mode 100644 index 0000000..530a0cd --- /dev/null +++ b/app/api/admin/device-link-conflicts/route.ts @@ -0,0 +1,133 @@ +/** + * 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 }); +} diff --git a/app/api/admin/rmm/settings/discover-loglift/route.ts b/app/api/admin/rmm/settings/discover-loglift/route.ts new file mode 100644 index 0000000..d5e9d33 --- /dev/null +++ b/app/api/admin/rmm/settings/discover-loglift/route.ts @@ -0,0 +1,41 @@ +/** + * POST /api/admin/rmm/settings/discover-loglift + * + * Force a re-scan of Datto RMM components, find the LogLift / event-log + * collector component, and update rmm_settings. Returns the discovered + * { uid, name } or 404 if nothing matches the discovery pattern. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import { discoverLogliftComponent } from '@/lib/services/rmm/settings'; + +export async function POST(_request: NextRequest) { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + try { + const result = await discoverLogliftComponent(); + if (!result.discovered) { + return NextResponse.json( + { + error: 'No matching component found', + message: + 'Datto RMM did not return any component whose name matches /loglift|eventlog/i. Register the LogLift collector component and retry.', + }, + { status: 404 } + ); + } + return NextResponse.json({ + settings: result.settings, + discovered: result.discovered, + }); + } catch (err) { + return NextResponse.json( + { + error: 'Discovery failed', + message: err instanceof Error ? err.message : String(err), + }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/rmm/settings/discover/route.ts b/app/api/admin/rmm/settings/discover/route.ts new file mode 100644 index 0000000..c5e9f51 --- /dev/null +++ b/app/api/admin/rmm/settings/discover/route.ts @@ -0,0 +1,38 @@ +/** + * POST /api/admin/rmm/settings/discover + * + * Force a re-scan of Datto RMM components, find the Overshell, and update + * rmm_settings. Returns the discovered { uid, name } or 404 if nothing + * matches the discovery pattern. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import { discoverOvershellComponent } from '@/lib/services/rmm/settings'; + +export async function POST(_request: NextRequest) { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + try { + const result = await discoverOvershellComponent(); + if (!result.discovered) { + return NextResponse.json( + { + error: 'No matching component found', + message: + 'Datto RMM did not return any component whose name matches /overshell/i. Register a component (Account → ComStore → Run Command or similar) and retry.', + }, + { status: 404 } + ); + } + return NextResponse.json({ settings: result.settings, discovered: result.discovered }); + } catch (err) { + return NextResponse.json( + { + error: 'Discovery failed', + message: err instanceof Error ? err.message : String(err), + }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/rmm/settings/route.ts b/app/api/admin/rmm/settings/route.ts new file mode 100644 index 0000000..85fc908 --- /dev/null +++ b/app/api/admin/rmm/settings/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/admin/rmm/settings + * Returns the cached Overshell config + recent execution counts. + * + * PATCH /api/admin/rmm/settings + * Body: { overshellVariableName: string } + * Updates the variable name the Overshell component expects. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requirePermission } from '@/lib/auth-utils'; +import { + getRmmSettings, + updateOvershellVariableName, +} from '@/lib/services/rmm/settings'; +import postgresClient from '@/lib/services/postgres-client'; + +const PatchBody = z.object({ + overshellVariableName: z.string().min(1).max(100), +}); + +interface CountsRow { + total: string; + running: string; + failed_24h: string; +} + +export async function GET(_request: NextRequest) { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + const settings = await getRmmSettings(); + const counts = await postgresClient.query( + `SELECT + (SELECT COUNT(*)::text FROM rmm_executions) AS total, + (SELECT COUNT(*)::text FROM rmm_executions WHERE status = 'running') AS running, + (SELECT COUNT(*)::text FROM rmm_executions + WHERE status = 'failed' AND queued_at >= NOW() - INTERVAL '24 hours') AS failed_24h` + ); + return NextResponse.json({ + settings, + counts: counts.rows[0] ?? { total: '0', running: '0', failed_24h: '0' }, + }); +} + +export async function PATCH(request: NextRequest) { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + const body = await request.json().catch(() => ({})); + const parsed = PatchBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid body', details: parsed.error.issues }, + { status: 400 } + ); + } + const settings = await updateOvershellVariableName( + parsed.data.overshellVariableName + ); + return NextResponse.json({ settings }); +} diff --git a/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts b/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts new file mode 100644 index 0000000..9834ca0 --- /dev/null +++ b/app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts @@ -0,0 +1,144 @@ +/** + * GET /api/analyzer/analyses/:id/itglue-suggestions + * Returns matched IT Glue assets (flexible_asset + configuration) for the + * analysis, plus any existing ticket-scoped audits keyed by (assetType, assetId). + * + * POST /api/analyzer/analyses/:id/itglue-suggestions + * Body: { assetType: 'flexible_asset' | 'configuration', assetId, provider? } + * Runs a ticket-scoped audit (evidence = just this analysis). Returns the + * audit row. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAuth } from '@/lib/auth-utils'; +import { matchAssetsForAnalysis } from '@/lib/services/analyzer/asset-audit/asset-matcher'; +import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner'; +import { + getAssetAuditById, + getLatestTicketScopedAudit, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { + evaluateCost, + recordCostAuditDecision, +} from '@/lib/services/analyzer/cost-guard'; +import { ProviderEnum } from '@/lib/types/analyzer'; + +const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = { + anthropic: 0.05, + openrouter: 0.005, +}; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const matched = await matchAssetsForAnalysis(id); + if (!matched) { + return NextResponse.json( + { error: 'Analysis not found or not yet complete' }, + { status: 404 } + ); + } + + // For each match, look up an existing ticket-scoped audit if any. + const flexWithAudits = await Promise.all( + matched.flexibleAssets.map(async (m) => ({ + ...m, + latestAudit: await getLatestTicketScopedAudit(id, 'flexible_asset', m.id), + })) + ); + const configWithAudits = await Promise.all( + matched.configurations.map(async (m) => ({ + ...m, + latestAudit: await getLatestTicketScopedAudit(id, 'configuration', m.id), + })) + ); + + return NextResponse.json({ + ticketNumber: matched.ticketNumber, + organizationId: matched.organizationId, + organizationName: matched.organizationName, + flexibleAssets: flexWithAudits, + configurations: configWithAudits, + }); +} + +const PostBody = z.object({ + assetType: z.enum(['flexible_asset', 'configuration']), + assetId: z.union([z.string(), z.number()]), + provider: ProviderEnum.optional().default('anthropic'), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireAuth(); + if (error) return error; + + const { id: analysisId } = await params; + const body = await request.json().catch(() => ({})); + const parsed = PostBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const { assetType, assetId, provider } = parsed.data; + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + + const evaluation = await evaluateCost({ + userId, + estimatedCost: PER_AUDIT_COST_USD[provider], + confirmedCost: false, + }); + await recordCostAuditDecision({ + userId, + action: 'itglue_audit', + evaluation, + context: { + mode: 'ticket_scoped', + analysisId, + assetType, + assetId, + provider, + }, + }); + if (evaluation.decision === 'blocked') { + return NextResponse.json( + { + error: 'Daily cost limit reached', + message: evaluation.decisionReason, + }, + { status: 403 } + ); + } + + const result = await runAssetAudit({ + assetType, + assetId, + generatedByUserId: userId, + provider, + ticketScopeAnalysisId: analysisId, + }); + + if (result.status === 'failed') { + return NextResponse.json( + { + error: 'Audit failed', + message: result.errorMessage, + auditId: result.auditId, + }, + { status: 500 } + ); + } + + const audit = await getAssetAuditById(result.auditId); + return NextResponse.json({ audit }); +} diff --git a/app/api/analyzer/analyses/[id]/share/route.ts b/app/api/analyzer/analyses/[id]/share/route.ts index ebc982a..725560c 100644 --- a/app/api/analyzer/analyses/[id]/share/route.ts +++ b/app/api/analyzer/analyses/[id]/share/route.ts @@ -17,6 +17,7 @@ import { createShare, getAnalysisById, } from '@/lib/services/analyzer/persistence'; +import postgresClient from '@/lib/services/postgres-client'; import { sendAnalysisShareEmail } from '@/lib/services/email'; function getAllowedDomains(): string[] { @@ -92,6 +93,32 @@ export async function POST( note, }); + // Fetch the ticket title for the email subtitle. Best-effort: a missing + // ticket (analyzer mirror skew) shouldn't fail the share. + let ticketTitle: string | null = null; + try { + const titleRes = await postgresClient.query<{ title: string | null }>( + `SELECT title FROM tickets + WHERE ticket_number = $1 + AND COALESCE(is_deleted, false) = false + LIMIT 1`, + [analysis.ticketNumber] + ); + if (titleRes.rowCount && titleRes.rowCount > 0) { + ticketTitle = titleRes.rows[0].title; + } + } catch { + // Title is decorative — proceed without it. + } + + const modelTier: 'haiku' | 'sonnet' | 'opus' | null = analysis.opusUsed + ? 'opus' + : analysis.sonnetUsed + ? 'sonnet' + : analysis.haikuUsed + ? 'haiku' + : null; + const analysisUrl = `${getAppBaseUrl().replace(/\/$/, '')}/analyzer/analysis/${id}`; let emailSent = true; let emailError: string | null = null; @@ -101,9 +128,16 @@ export async function POST( senderName: sessionUser.name || sessionUser.email, senderEmail: sessionUser.email, ticketNumber: analysis.ticketNumber, + ticketTitle, analysisVersion: analysis.analysisVersion, summary: analysis.summary, nextStep: analysis.nextStep, + nextStepRationale: analysis.nextStepRationale, + whatWasDone: analysis.whatWasDone, + whatShouldHaveBeenDone: analysis.whatShouldHaveBeenDone, + gaps: analysis.gaps, + confidenceScore: analysis.confidenceScore, + modelTier, analysisUrl, note, }); diff --git a/app/api/analyzer/itglue/applications/[id]/apply/route.ts b/app/api/analyzer/itglue/applications/[id]/apply/route.ts new file mode 100644 index 0000000..b65999a --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/apply/route.ts @@ -0,0 +1,201 @@ +/** + * POST /api/analyzer/itglue/applications/:id/apply + * + * Body: ApplyAssetSuggestionRequest = { auditId, fieldName, suggestedValue, sourceEvidence? } + * + * Flow: + * 1. Verify auth + itglue.write permission. + * 2. Read the asset's current traits (the source of truth for before_value). + * 3. Insert pending row in itglue_writes capturing before/after. + * 4. Call IT Glue PATCH /flexible_assets/:id (merged trait map). + * 5. On success: mark write committed, refresh the local mirror row, write + * a generic audit_log entry. + * 6. On failure: mark write failed, return 502. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer'; +import { + createPendingWrite, + fieldNameToTraitKey, + getAssetAuditById, + markWriteCommitted, + markWriteFailed, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs'; +import { getITGlueClient } from '@/lib/services/itglue-client'; +import { getITGlueSyncService } from '@/lib/services/itglue-sync-service'; +import { audit } from '@/lib/services/audit'; + +interface AssetRow { + id: string; + organization_name: string | null; + flexible_asset_type_id: string; + traits: Record; +} + +async function loadAssetRow(assetId: string): Promise { + const res = await postgresClient.query( + `SELECT id::text AS id, + organization_name, + flexible_asset_type_id::text AS flexible_asset_type_id, + traits + FROM itg_flexible_assets + WHERE id = $1 + LIMIT 1`, + [assetId] + ); + return res.rowCount === 0 ? null : res.rows[0]; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('itglue', 'write'); + if (error) return error; + + const { id: assetId } = await params; + const body = await request.json().catch(() => ({})); + const parsed = ApplyAssetSuggestionRequest.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data; + + // Sanity-check: the audit must exist and reference this asset. + const auditRow = await getAssetAuditById(auditId); + if (!auditRow) { + return NextResponse.json({ error: 'Audit not found' }, { status: 404 }); + } + if (auditRow.asset_id !== assetId) { + return NextResponse.json( + { error: 'Audit does not reference this asset' }, + { status: 400 } + ); + } + + // Refuse to write to credential-shaped fields. Belt for the prompt's + // braces; the prompt already tells the LLM not to suggest these, but + // re-block here so a malicious-looking payload can't sneak through. + const lowerField = fieldName.toLowerCase(); + if ( + /(password|secret|key|token|credential)/.test(lowerField) + ) { + return NextResponse.json( + { error: 'Refusing to write to credential-shaped field' }, + { status: 400 } + ); + } + + // Read current traits. + const asset = await loadAssetRow(assetId); + if (!asset) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + const traitKey = fieldNameToTraitKey(fieldName); + const beforeValue = asset.traits[traitKey] ?? null; + const userId = + (session?.user as { id: string; email?: string } | undefined)?.id ?? null; + const userEmail = + (session?.user as { id: string; email?: string } | undefined)?.email ?? + undefined; + + // Insert pending row first so we never write to IT Glue without an audit + // row in flight. + const writeRow = await createPendingWrite({ + audit_id: auditId, + asset_type: 'flexible_asset', + asset_id: assetId, + field_name: fieldName, + before_value: beforeValue, + after_value: suggestedValue, + performed_by_user_id: userId, + source_evidence: sourceEvidence ?? null, + triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null, + }); + + // Build merged trait map. IT Glue replaces the trait set on PATCH, so we + // must include unchanged traits. + const merged: Record = { + ...asset.traits, + [traitKey]: suggestedValue, + }; + + try { + const client = getITGlueClient(); + const updated = await client.updateFlexibleAsset(assetId, merged); + await markWriteCommitted(writeRow.id, updated); + + // Best-effort: refresh the mirror so subsequent reads see the new value + // without waiting for the next fullSync. + try { + await getITGlueSyncService().refreshFlexibleAssetById(assetId); + } catch (refreshErr) { + console.warn( + `[itglue-apply] mirror refresh failed for asset ${assetId}:`, + refreshErr instanceof Error ? refreshErr.message : refreshErr + ); + } + + // Generic admin-visible audit log row. + await audit.log({ + userId: userId ?? undefined, + userEmail, + action: 'itglue.write', + resource: 'flexible_asset', + resourceId: assetId, + details: { + write_id: writeRow.id, + audit_id: auditId, + field_name: fieldName, + trait_key: traitKey, + before: beforeValue, + after: suggestedValue, + }, + }); + + // Phase 4.1: cross-reference row when this write was triggered by a + // ticket-scoped audit. Best-effort. + if (auditRow.triggered_by_ticket_number) { + try { + await insertUpdatedXref({ + ticketNumber: auditRow.triggered_by_ticket_number, + analysisId: auditRow.triggered_by_analysis_id ?? null, + assetType: 'flexible_asset', + assetId, + writeId: writeRow.id, + fieldName, + }); + } catch (xrefErr) { + console.warn( + `[itglue-apply] xref insert failed for write ${writeRow.id}:`, + xrefErr instanceof Error ? xrefErr.message : xrefErr + ); + } + } + + return NextResponse.json({ + writeId: writeRow.id, + status: 'committed', + asset: updated, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await markWriteFailed(writeRow.id, message); + return NextResponse.json( + { + writeId: writeRow.id, + status: 'failed', + error: 'IT Glue write failed', + message, + }, + { status: 502 } + ); + } +} diff --git a/app/api/analyzer/itglue/applications/[id]/audit/route.ts b/app/api/analyzer/itglue/applications/[id]/audit/route.ts new file mode 100644 index 0000000..1b8a37c --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/audit/route.ts @@ -0,0 +1,113 @@ +/** + * GET /api/analyzer/itglue/applications/:id/audit + * Returns the latest audit row for the asset (or null). + * + * POST /api/analyzer/itglue/applications/:id/audit + * Body: { provider?: 'anthropic' | 'openrouter' } + * Runs a fresh audit. Cost-guarded the same way single-ticket runs are. + * Returns the new audit row. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { RunAssetAuditRequest } from '@/lib/types/analyzer'; +import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner'; +import { + getAssetAuditById, + getLatestAssetAudit, + listAssetAudits, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { + evaluateCost, + recordCostAuditDecision, +} from '@/lib/services/analyzer/cost-guard'; + +const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = { + anthropic: 0.1, + openrouter: 0.01, +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const url = new URL(request.url); + const includeHistory = url.searchParams.get('history') === '1'; + + const latest = await getLatestAssetAudit(id); + if (!includeHistory) { + return NextResponse.json({ audit: latest }); + } + const history = await listAssetAudits(id, 'flexible_asset', 20); + return NextResponse.json({ audit: latest, history }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const body = await request.json().catch(() => ({})); + const parsed = RunAssetAuditRequest.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const provider = parsed.data.provider; + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + + const evaluation = await evaluateCost({ + userId, + estimatedCost: PER_AUDIT_COST_USD[provider], + confirmedCost: false, + }); + await recordCostAuditDecision({ + userId, + action: 'itglue_audit', + evaluation, + context: { assetId: id, provider }, + }); + if (evaluation.decision === 'blocked') { + return NextResponse.json( + { + error: 'Daily cost limit reached', + message: evaluation.decisionReason, + estimatedCost: evaluation.estimatedCost, + dailySpendBefore: evaluation.dailySpendBefore, + }, + { status: 403 } + ); + } + // Audits are cheap enough that requires_confirmation should never trip + // the per-request threshold — but let it fall through anyway. + + const result = await runAssetAudit({ + assetType: 'flexible_asset', + assetId: id, + generatedByUserId: userId, + provider, + }); + + if (result.status === 'failed') { + return NextResponse.json( + { + error: 'Audit failed', + message: result.errorMessage, + auditId: result.auditId, + }, + { status: 500 } + ); + } + + const audit = await getAssetAuditById(result.auditId); + return NextResponse.json({ audit }); +} diff --git a/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts b/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts new file mode 100644 index 0000000..2814e14 --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts @@ -0,0 +1,146 @@ +/** + * POST /api/analyzer/itglue/applications/:id/revert/:writeId + * + * Reverts a previously committed write by re-applying its before_value. + * Implementation: insert a NEW itglue_writes row with reversed before/after, + * call IT Glue PATCH with the original before_value, then mark the original + * row status='reverted'. Audit log captures both rows. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + createPendingWrite, + fieldNameToTraitKey, + getWriteById, + markWriteCommitted, + markWriteFailed, + markWriteReverted, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { getITGlueClient } from '@/lib/services/itglue-client'; +import { getITGlueSyncService } from '@/lib/services/itglue-sync-service'; +import { audit } from '@/lib/services/audit'; + +interface AssetRow { + id: string; + traits: Record; +} + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string; writeId: string }> } +) { + const { session, error } = await requirePermission('itglue', 'write'); + if (error) return error; + + const { id: assetId, writeId } = await params; + const userId = + (session?.user as { id: string; email?: string } | undefined)?.id ?? null; + const userEmail = + (session?.user as { id: string; email?: string } | undefined)?.email ?? + undefined; + + const original = await getWriteById(writeId); + if (!original) { + return NextResponse.json({ error: 'Write not found' }, { status: 404 }); + } + if (original.asset_id !== assetId) { + return NextResponse.json( + { error: 'Write does not reference this asset' }, + { status: 400 } + ); + } + if (original.status === 'reverted') { + return NextResponse.json( + { error: 'Write already reverted' }, + { status: 400 } + ); + } + if (original.status !== 'committed') { + return NextResponse.json( + { error: `Cannot revert a write in status '${original.status}'` }, + { status: 400 } + ); + } + + // Read current asset traits to merge cleanly. + const assetRes = await postgresClient.query( + `SELECT id::text AS id, traits FROM itg_flexible_assets WHERE id = $1 LIMIT 1`, + [assetId] + ); + if (assetRes.rowCount === 0) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + const traits = assetRes.rows[0].traits; + const traitKey = fieldNameToTraitKey(original.field_name); + + // Build the revert: swap original's before/after; the new "after" value is + // the original's "before". + const revertRow = await createPendingWrite({ + audit_id: original.audit_id, + asset_type: 'flexible_asset', + asset_id: assetId, + field_name: original.field_name, + before_value: original.after_value, + after_value: original.before_value, + performed_by_user_id: userId, + source_evidence: { reverts_write_id: original.id }, + }); + + const merged: Record = { + ...traits, + [traitKey]: original.before_value, + }; + + try { + const client = getITGlueClient(); + const updated = await client.updateFlexibleAsset(assetId, merged); + + await markWriteCommitted(revertRow.id, updated); + await markWriteReverted(original.id); + + try { + await getITGlueSyncService().refreshFlexibleAssetById(assetId); + } catch (refreshErr) { + console.warn( + `[itglue-revert] mirror refresh failed for asset ${assetId}:`, + refreshErr instanceof Error ? refreshErr.message : refreshErr + ); + } + + await audit.log({ + userId: userId ?? undefined, + userEmail, + action: 'itglue.revert', + resource: 'flexible_asset', + resourceId: assetId, + details: { + revert_write_id: revertRow.id, + original_write_id: original.id, + field_name: original.field_name, + before: original.after_value, + after: original.before_value, + }, + }); + + return NextResponse.json({ + writeId: revertRow.id, + revertedWriteId: original.id, + status: 'committed', + asset: updated, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await markWriteFailed(revertRow.id, message); + return NextResponse.json( + { + writeId: revertRow.id, + status: 'failed', + error: 'IT Glue revert failed', + message, + }, + { status: 502 } + ); + } +} diff --git a/app/api/analyzer/itglue/applications/[id]/route.ts b/app/api/analyzer/itglue/applications/[id]/route.ts new file mode 100644 index 0000000..6d095de --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/route.ts @@ -0,0 +1,88 @@ +/** + * GET /api/analyzer/itglue/applications/:id + * + * Returns the asset row + its type's field schema (with hints) so the + * detail page can render fields in IT Glue's order with empty fields shown + * muted. Read-only. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { redact } from '@/lib/services/analyzer/itglue-redact'; + +interface AssetRow { + id: string; + name: string | null; + organization_id: string | null; + organization_name: string | null; + flexible_asset_type_id: string; + flexible_asset_type_name: string | null; + traits: Record; + created_at: Date | null; + updated_at: Date | null; +} + +interface FieldRow { + id: string; + name: string; + kind: string | null; + hint: string | null; + required: boolean; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + + const assetRes = await postgresClient.query( + `SELECT a.id::text AS id, + a.name, + a.organization_id::text AS organization_id, + a.organization_name, + a.flexible_asset_type_id::text AS flexible_asset_type_id, + a.flexible_asset_type_name, + a.traits, + a.created_at, + a.updated_at, + comp.id::text AS autotask_company_id + FROM itg_flexible_assets a + LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(a.organization_name) + WHERE a.id = $1 + LIMIT 1`, + [id] + ); + if (assetRes.rowCount === 0) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + const a = assetRes.rows[0]; + + const fieldsRes = await postgresClient.query( + `SELECT id::text AS id, name, kind, hint, required + FROM itg_flexible_asset_fields + WHERE flexible_asset_type_id = $1 + ORDER BY id`, + [a.flexible_asset_type_id] + ); + + return NextResponse.json({ + asset: { + id: a.id, + name: a.name, + organizationId: a.organization_id, + organizationName: a.organization_name, + flexibleAssetTypeId: a.flexible_asset_type_id, + flexibleAssetTypeName: a.flexible_asset_type_name, + autotaskCompanyId: a.autotask_company_id, + traits: redact(a.traits ?? {}), + createdAt: a.created_at?.toISOString() ?? null, + updatedAt: a.updated_at?.toISOString() ?? null, + }, + fields: fieldsRes.rows, + }); +} diff --git a/app/api/analyzer/itglue/applications/[id]/writes/route.ts b/app/api/analyzer/itglue/applications/[id]/writes/route.ts new file mode 100644 index 0000000..fe84d88 --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/writes/route.ts @@ -0,0 +1,22 @@ +/** + * GET /api/analyzer/itglue/applications/:id/writes + * + * Returns the write history for a single asset (for the asset detail page). + * Auth-only — admins use the same data plus the dedicated /admin/itglue-writes + * page for cross-asset views. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const writes = await listWritesForAsset(id, 50); + return NextResponse.json({ writes }); +} diff --git a/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts b/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts new file mode 100644 index 0000000..37ad151 --- /dev/null +++ b/app/api/analyzer/itglue/applications/[id]/xrefs/route.ts @@ -0,0 +1,21 @@ +/** + * GET /api/analyzer/itglue/applications/:id/xrefs + * + * Returns the xref rows for this flexible asset — "tickets that referenced + * me" + "tickets that updated me" — for the asset detail page. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const xrefs = await listXrefsForAsset('flexible_asset', id, 100); + return NextResponse.json({ xrefs }); +} diff --git a/app/api/analyzer/itglue/applications/route.ts b/app/api/analyzer/itglue/applications/route.ts new file mode 100644 index 0000000..ed10975 --- /dev/null +++ b/app/api/analyzer/itglue/applications/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/analyzer/itglue/applications + * + * Returns all Application flexible-asset records, joined to their latest + * audit (if any). Powers /analyzer/itglue/applications listing page. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +const APPLICATION_TYPE_ID = 3790; + +interface Row { + id: string; + name: string | null; + organization_id: string | null; + organization_name: string | null; + trait_count: number; + latest_audit_id: string | null; + latest_audit_at: Date | null; + latest_audit_score: number | null; + latest_audit_provider: 'anthropic' | 'openrouter' | null; +} + +export async function GET(_request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const res = await postgresClient.query( + `SELECT a.id::text AS id, + a.name, + a.organization_id::text AS organization_id, + a.organization_name, + (SELECT COUNT(*)::int + FROM jsonb_object_keys(a.traits) k) AS trait_count, + la.id::text AS latest_audit_id, + la.generated_at AS latest_audit_at, + la.overall_score::float8 AS latest_audit_score, + la.provider AS latest_audit_provider + FROM itg_flexible_assets a + LEFT JOIN LATERAL ( + SELECT id, generated_at, overall_score, provider + FROM itglue_asset_audits + WHERE asset_type = 'flexible_asset' + AND asset_id = a.id + AND status = 'complete' + ORDER BY generated_at DESC + LIMIT 1 + ) la ON true + WHERE a.flexible_asset_type_id = $1 + AND COALESCE(a.archived, false) = false + ORDER BY + la.overall_score ASC NULLS FIRST, + a.organization_name, + a.name`, + [APPLICATION_TYPE_ID] + ); + + const applications = res.rows.map((r) => ({ + id: r.id, + name: r.name, + organizationId: r.organization_id, + organizationName: r.organization_name, + traitCount: r.trait_count, + latestAudit: r.latest_audit_id + ? { + id: r.latest_audit_id, + generatedAt: r.latest_audit_at?.toISOString() ?? null, + overallScore: r.latest_audit_score, + provider: r.latest_audit_provider, + } + : null, + })); + return NextResponse.json({ applications }); +} diff --git a/app/api/analyzer/itglue/configurations/[id]/apply/route.ts b/app/api/analyzer/itglue/configurations/[id]/apply/route.ts new file mode 100644 index 0000000..17377ed --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/apply/route.ts @@ -0,0 +1,215 @@ +/** + * POST /api/analyzer/itglue/configurations/:id/apply + * + * Same shape as the Applications apply route but PATCHes /configurations/:id + * with flat attributes (no traits blob). All audit-trail layers identical. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer'; +import { + createPendingWrite, + getAssetAuditById, + markWriteCommitted, + markWriteFailed, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs'; +import { getITGlueClient } from '@/lib/services/itglue-client'; +import { getITGlueSyncService } from '@/lib/services/itglue-sync-service'; +import { audit } from '@/lib/services/audit'; + +interface ConfigRow { + id: string; + name: string; + hostname: string | null; + primary_ip: string | null; + mac_address: string | null; + serial_number: string | null; + asset_tag: string | null; + position: string | null; + notes: string | null; + operating_system_notes: string | null; +} + +const CONFIG_EDITABLE_COLUMNS = [ + 'name', + 'hostname', + 'primary_ip', + 'mac_address', + 'serial_number', + 'asset_tag', + 'position', + 'notes', + 'operating_system_notes', +] as const; + +const FIELD_TO_ITGLUE_ATTR: Record = { + name: 'name', + hostname: 'hostname', + primary_ip: 'primary-ip', + mac_address: 'mac-address', + serial_number: 'serial-number', + asset_tag: 'asset-tag', + position: 'position', + notes: 'notes', + operating_system_notes: 'operating-system-notes', +}; + +async function loadConfigurationRow(assetId: string): Promise { + const res = await postgresClient.query( + `SELECT id::text AS id, + name, hostname, primary_ip, mac_address, serial_number, + asset_tag, position, notes, operating_system_notes + FROM itg_configurations + WHERE id = $1 + LIMIT 1`, + [assetId] + ); + return res.rowCount === 0 ? null : res.rows[0]; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('itglue', 'write'); + if (error) return error; + + const { id: assetId } = await params; + const body = await request.json().catch(() => ({})); + const parsed = ApplyAssetSuggestionRequest.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data; + + const auditRow = await getAssetAuditById(auditId); + if (!auditRow) { + return NextResponse.json({ error: 'Audit not found' }, { status: 404 }); + } + if (auditRow.asset_id !== assetId || auditRow.asset_type !== 'configuration') { + return NextResponse.json( + { error: 'Audit does not reference this configuration' }, + { status: 400 } + ); + } + + const lowerField = fieldName.toLowerCase(); + if (/(password|secret|key|token|credential)/.test(lowerField)) { + return NextResponse.json( + { error: 'Refusing to write to credential-shaped field' }, + { status: 400 } + ); + } + + // Refuse to write fields the audit pipeline shouldn't be touching on a + // Configuration. We only let it edit the columns flagged editable. + if (!CONFIG_EDITABLE_COLUMNS.includes(fieldName as (typeof CONFIG_EDITABLE_COLUMNS)[number])) { + return NextResponse.json( + { + error: `Field '${fieldName}' is not editable via the audit pipeline`, + editableFields: CONFIG_EDITABLE_COLUMNS, + }, + { status: 400 } + ); + } + + const config = await loadConfigurationRow(assetId); + if (!config) { + return NextResponse.json({ error: 'Configuration not found' }, { status: 404 }); + } + const beforeValue = + (config as unknown as Record)[fieldName] ?? null; + const userId = + (session?.user as { id: string; email?: string } | undefined)?.id ?? null; + const userEmail = + (session?.user as { id: string; email?: string } | undefined)?.email ?? + undefined; + + const writeRow = await createPendingWrite({ + audit_id: auditId, + asset_type: 'configuration', + asset_id: assetId, + field_name: fieldName, + before_value: beforeValue, + after_value: suggestedValue, + performed_by_user_id: userId, + source_evidence: sourceEvidence ?? null, + triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null, + }); + + // IT Glue PATCH attribute — convert snake to dash form. + const attrKey = FIELD_TO_ITGLUE_ATTR[fieldName]; + const attributes = { [attrKey]: suggestedValue }; + + try { + const client = getITGlueClient(); + const updated = await client.updateConfiguration(assetId, attributes); + await markWriteCommitted(writeRow.id, updated); + + try { + await getITGlueSyncService().refreshConfigurationById(assetId); + } catch (refreshErr) { + console.warn( + `[itglue-config-apply] mirror refresh failed for ${assetId}:`, + refreshErr instanceof Error ? refreshErr.message : refreshErr + ); + } + + await audit.log({ + userId: userId ?? undefined, + userEmail, + action: 'itglue.write', + resource: 'configuration', + resourceId: assetId, + details: { + write_id: writeRow.id, + audit_id: auditId, + field_name: fieldName, + before: beforeValue, + after: suggestedValue, + }, + }); + + if (auditRow.triggered_by_ticket_number) { + try { + await insertUpdatedXref({ + ticketNumber: auditRow.triggered_by_ticket_number, + analysisId: auditRow.triggered_by_analysis_id ?? null, + assetType: 'configuration', + assetId, + writeId: writeRow.id, + fieldName, + }); + } catch (xrefErr) { + console.warn( + `[itglue-config-apply] xref insert failed for write ${writeRow.id}:`, + xrefErr instanceof Error ? xrefErr.message : xrefErr + ); + } + } + + return NextResponse.json({ + writeId: writeRow.id, + status: 'committed', + asset: updated, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await markWriteFailed(writeRow.id, message); + return NextResponse.json( + { + writeId: writeRow.id, + status: 'failed', + error: 'IT Glue write failed', + message, + }, + { status: 502 } + ); + } +} diff --git a/app/api/analyzer/itglue/configurations/[id]/audit/route.ts b/app/api/analyzer/itglue/configurations/[id]/audit/route.ts new file mode 100644 index 0000000..4042027 --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/audit/route.ts @@ -0,0 +1,104 @@ +/** + * GET /api/analyzer/itglue/configurations/:id/audit + * POST /api/analyzer/itglue/configurations/:id/audit + * + * Mirrors the Applications endpoint but for Configurations. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { RunAssetAuditRequest } from '@/lib/types/analyzer'; +import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner'; +import { + getAssetAuditById, + getLatestAssetAudit, + listAssetAudits, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { + evaluateCost, + recordCostAuditDecision, +} from '@/lib/services/analyzer/cost-guard'; + +const PER_AUDIT_COST_USD: Record<'anthropic' | 'openrouter', number> = { + anthropic: 0.1, + openrouter: 0.01, +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const url = new URL(request.url); + const includeHistory = url.searchParams.get('history') === '1'; + + const latest = await getLatestAssetAudit(id, 'configuration'); + if (!includeHistory) return NextResponse.json({ audit: latest }); + const history = await listAssetAudits(id, 'configuration', 20); + return NextResponse.json({ audit: latest, history }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const body = await request.json().catch(() => ({})); + const parsed = RunAssetAuditRequest.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + const provider = parsed.data.provider; + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + + const evaluation = await evaluateCost({ + userId, + estimatedCost: PER_AUDIT_COST_USD[provider], + confirmedCost: false, + }); + await recordCostAuditDecision({ + userId, + action: 'itglue_audit', + evaluation, + context: { assetId: id, assetType: 'configuration', provider }, + }); + if (evaluation.decision === 'blocked') { + return NextResponse.json( + { + error: 'Daily cost limit reached', + message: evaluation.decisionReason, + }, + { status: 403 } + ); + } + + const result = await runAssetAudit({ + assetType: 'configuration', + assetId: id, + generatedByUserId: userId, + provider, + }); + + if (result.status === 'failed') { + return NextResponse.json( + { + error: 'Audit failed', + message: result.errorMessage, + auditId: result.auditId, + }, + { status: 500 } + ); + } + + const audit = await getAssetAuditById(result.auditId); + return NextResponse.json({ audit }); +} diff --git a/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts b/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts new file mode 100644 index 0000000..808390b --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/revert/[writeId]/route.ts @@ -0,0 +1,147 @@ +/** + * POST /api/analyzer/itglue/configurations/:id/revert/:writeId + * + * Mirrors the Applications revert path but PATCHes /configurations/:id. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + createPendingWrite, + getWriteById, + markWriteCommitted, + markWriteFailed, + markWriteReverted, +} from '@/lib/services/analyzer/asset-audit/persistence'; +import { getITGlueClient } from '@/lib/services/itglue-client'; +import { getITGlueSyncService } from '@/lib/services/itglue-sync-service'; +import { audit } from '@/lib/services/audit'; + +const FIELD_TO_ITGLUE_ATTR: Record = { + name: 'name', + hostname: 'hostname', + primary_ip: 'primary-ip', + mac_address: 'mac-address', + serial_number: 'serial-number', + asset_tag: 'asset-tag', + position: 'position', + notes: 'notes', + operating_system_notes: 'operating-system-notes', +}; + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string; writeId: string }> } +) { + const { session, error } = await requirePermission('itglue', 'write'); + if (error) return error; + + const { id: assetId, writeId } = await params; + const userId = + (session?.user as { id: string; email?: string } | undefined)?.id ?? null; + const userEmail = + (session?.user as { id: string; email?: string } | undefined)?.email ?? + undefined; + + const original = await getWriteById(writeId); + if (!original) return NextResponse.json({ error: 'Write not found' }, { status: 404 }); + if (original.asset_id !== assetId || original.asset_type !== 'configuration') { + return NextResponse.json( + { error: 'Write does not reference this configuration' }, + { status: 400 } + ); + } + if (original.status === 'reverted') { + return NextResponse.json( + { error: 'Write already reverted' }, + { status: 400 } + ); + } + if (original.status !== 'committed') { + return NextResponse.json( + { error: `Cannot revert a write in status '${original.status}'` }, + { status: 400 } + ); + } + + const exists = await postgresClient.query( + `SELECT 1 FROM itg_configurations WHERE id = $1 LIMIT 1`, + [assetId] + ); + if (exists.rowCount === 0) { + return NextResponse.json({ error: 'Configuration not found' }, { status: 404 }); + } + + const attrKey = FIELD_TO_ITGLUE_ATTR[original.field_name]; + if (!attrKey) { + return NextResponse.json( + { error: `Cannot map field '${original.field_name}' to IT Glue attribute` }, + { status: 400 } + ); + } + + const revertRow = await createPendingWrite({ + audit_id: original.audit_id, + asset_type: 'configuration', + asset_id: assetId, + field_name: original.field_name, + before_value: original.after_value, + after_value: original.before_value, + performed_by_user_id: userId, + source_evidence: { reverts_write_id: original.id }, + }); + + try { + const client = getITGlueClient(); + const updated = await client.updateConfiguration(assetId, { + [attrKey]: original.before_value, + }); + + await markWriteCommitted(revertRow.id, updated); + await markWriteReverted(original.id); + + try { + await getITGlueSyncService().refreshConfigurationById(assetId); + } catch (refreshErr) { + console.warn( + `[itglue-config-revert] mirror refresh failed for ${assetId}:`, + refreshErr instanceof Error ? refreshErr.message : refreshErr + ); + } + + await audit.log({ + userId: userId ?? undefined, + userEmail, + action: 'itglue.revert', + resource: 'configuration', + resourceId: assetId, + details: { + revert_write_id: revertRow.id, + original_write_id: original.id, + field_name: original.field_name, + before: original.after_value, + after: original.before_value, + }, + }); + + return NextResponse.json({ + writeId: revertRow.id, + revertedWriteId: original.id, + status: 'committed', + asset: updated, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await markWriteFailed(revertRow.id, message); + return NextResponse.json( + { + writeId: revertRow.id, + status: 'failed', + error: 'IT Glue revert failed', + message, + }, + { status: 502 } + ); + } +} diff --git a/app/api/analyzer/itglue/configurations/[id]/route.ts b/app/api/analyzer/itglue/configurations/[id]/route.ts new file mode 100644 index 0000000..70ea864 --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/route.ts @@ -0,0 +1,135 @@ +/** + * GET /api/analyzer/itglue/configurations/:id + * + * Returns one Configuration row + the curated field schema (with hints) so + * the detail page renders fields in a stable order. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { _ASSET_AUDIT_INTERNALS } from '@/lib/services/analyzer/asset-audit/data-builder'; + +interface ConfigRow { + id: string; + organization_id: string | null; + organization_name: string | null; + configuration_type_id: string | null; + configuration_type_name: string | null; + configuration_status_id: string | null; + configuration_status_name: string | null; + manufacturer_name: string | null; + model_name: string | null; + operating_system_name: string | null; + contact_id: string | null; + location_id: string | null; + name: string; + hostname: string | null; + primary_ip: string | null; + mac_address: string | null; + serial_number: string | null; + asset_tag: string | null; + position: string | null; + notes: string | null; + operating_system_notes: string | null; + created_at: Date | null; + updated_at: Date | null; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + + const res = await postgresClient.query( + `SELECT c.id::text AS id, + c.organization_id::text AS organization_id, + c.organization_name, + c.configuration_type_id::text AS configuration_type_id, + c.configuration_type_name, + c.configuration_status_id::text AS configuration_status_id, + c.configuration_status_name, + c.manufacturer_name, c.model_name, + c.operating_system_name, + c.contact_id::text AS contact_id, + c.location_id::text AS location_id, + c.name, c.hostname, c.primary_ip, c.mac_address, c.serial_number, c.asset_tag, + c.position, c.notes, c.operating_system_notes, + c.created_at, c.updated_at, + c.rmm_id, + comp.id::text AS autotask_company_id + FROM itg_configurations c + LEFT JOIN companies comp ON LOWER(comp.company_name) = LOWER(c.organization_name) + WHERE c.id = $1 + LIMIT 1`, + [id] + ); + if (res.rowCount === 0) { + return NextResponse.json({ error: 'Configuration not found' }, { status: 404 }); + } + const c = res.rows[0]; + + // If we have an rmm_id, look up the Datto device uid for the picker. + let dattoDeviceUid: string | null = null; + if (c.rmm_id) { + const drmm = await postgresClient.query<{ uid: string }>( + `SELECT uid FROM datto_rmm_devices WHERE id::text = $1 OR uid = $1 LIMIT 1`, + [c.rmm_id] + ); + dattoDeviceUid = drmm.rows[0]?.uid ?? null; + } + // Fallback: hostname-based device lookup. + if (!dattoDeviceUid && c.hostname) { + const drmm = await postgresClient.query<{ uid: string }>( + `SELECT uid FROM datto_rmm_devices WHERE LOWER(hostname) = LOWER($1) LIMIT 1`, + [c.hostname] + ); + dattoDeviceUid = drmm.rows[0]?.uid ?? null; + } + + return NextResponse.json({ + asset: { + id: c.id, + name: c.name, + hostname: c.hostname, + organizationId: c.organization_id, + organizationName: c.organization_name, + typeId: c.configuration_type_id, + typeName: c.configuration_type_name, + statusName: c.configuration_status_name, + manufacturerName: c.manufacturer_name, + modelName: c.model_name, + operatingSystemName: c.operating_system_name, + contactId: c.contact_id, + locationId: c.location_id, + dattoDeviceUid, + autotaskCompanyId: c.autotask_company_id, + // Synthesized "fields" map keyed by the audit field names. + traits: { + name: c.name, + hostname: c.hostname, + primary_ip: c.primary_ip, + mac_address: c.mac_address, + serial_number: c.serial_number, + asset_tag: c.asset_tag, + position: c.position, + configuration_type_name: c.configuration_type_name, + configuration_status_name: c.configuration_status_name, + manufacturer_name: c.manufacturer_name, + model_name: c.model_name, + operating_system_name: c.operating_system_name, + operating_system_notes: c.operating_system_notes, + notes: c.notes, + contact_id: c.contact_id, + location_id: c.location_id, + }, + createdAt: c.created_at?.toISOString() ?? null, + updatedAt: c.updated_at?.toISOString() ?? null, + }, + fields: _ASSET_AUDIT_INTERNALS.CONFIGURATION_FIELDS, + }); +} diff --git a/app/api/analyzer/itglue/configurations/[id]/writes/route.ts b/app/api/analyzer/itglue/configurations/[id]/writes/route.ts new file mode 100644 index 0000000..05bf334 --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/writes/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/analyzer/itglue/configurations/:id/writes + * + * Per-configuration write history. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listWritesForAsset } from '@/lib/services/analyzer/asset-audit/persistence'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const writes = await listWritesForAsset(id, 50); + // Filter to configuration writes — listWritesForAsset doesn't filter by + // asset_type, but a given asset_id only ever exists under one type so this + // is functionally equivalent. Defensive filter: + const filtered = writes.filter((w) => w.asset_type === 'configuration'); + return NextResponse.json({ writes: filtered }); +} diff --git a/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts b/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts new file mode 100644 index 0000000..7e44ec0 --- /dev/null +++ b/app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts @@ -0,0 +1,18 @@ +/** + * GET /api/analyzer/itglue/configurations/:id/xrefs + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listXrefsForAsset } from '@/lib/services/analyzer/asset-audit/xrefs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const xrefs = await listXrefsForAsset('configuration', id, 100); + return NextResponse.json({ xrefs }); +} diff --git a/app/api/analyzer/itglue/configurations/route.ts b/app/api/analyzer/itglue/configurations/route.ts new file mode 100644 index 0000000..2a93d6a --- /dev/null +++ b/app/api/analyzer/itglue/configurations/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/analyzer/itglue/configurations + * + * Returns Configuration records joined to their latest audit. Powers the + * /analyzer/itglue/configurations listing page. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface Row { + id: string; + name: string; + hostname: string | null; + configuration_type_name: string | null; + configuration_status_name: string | null; + organization_id: string | null; + organization_name: string | null; + latest_audit_id: string | null; + latest_audit_at: Date | null; + latest_audit_score: number | null; + latest_audit_provider: 'anthropic' | 'openrouter' | null; +} + +export async function GET(_request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const res = await postgresClient.query( + `SELECT c.id::text AS id, + c.name, + c.hostname, + c.configuration_type_name, + c.configuration_status_name, + c.organization_id::text AS organization_id, + c.organization_name, + la.id::text AS latest_audit_id, + la.generated_at AS latest_audit_at, + la.overall_score::float8 AS latest_audit_score, + la.provider AS latest_audit_provider + FROM itg_configurations c + LEFT JOIN LATERAL ( + SELECT id, generated_at, overall_score, provider + FROM itglue_asset_audits + WHERE asset_type = 'configuration' + AND asset_id = c.id + AND status = 'complete' + ORDER BY generated_at DESC + LIMIT 1 + ) la ON true + ORDER BY + la.overall_score ASC NULLS FIRST, + c.organization_name, + c.name` + ); + + const configurations = res.rows.map((r) => ({ + id: r.id, + name: r.name, + hostname: r.hostname, + typeName: r.configuration_type_name, + statusName: r.configuration_status_name, + organizationId: r.organization_id, + organizationName: r.organization_name, + latestAudit: r.latest_audit_id + ? { + id: r.latest_audit_id, + generatedAt: r.latest_audit_at?.toISOString() ?? null, + overallScore: r.latest_audit_score, + provider: r.latest_audit_provider, + } + : null, + })); + return NextResponse.json({ configurations }); +} diff --git a/app/api/analyzer/itglue/sites/[companyId]/route.ts b/app/api/analyzer/itglue/sites/[companyId]/route.ts new file mode 100644 index 0000000..b10a839 --- /dev/null +++ b/app/api/analyzer/itglue/sites/[companyId]/route.ts @@ -0,0 +1,67 @@ +/** + * GET /api/analyzer/itglue/sites/:companyId + * + * Returns the site-discovery summary for a client: company identity, + * IT Glue org name, Datto site mapping, and the resolved Wulf Nurse + * Production endpoint (hostname + uid + online state). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + listSiteAnchorTargets, + resolveSiteAnchorTarget, +} from '@/lib/services/rmm/target-resolver'; + +interface CompanyRow { + id: string; + company_name: string | null; + itglue_org_id: string | null; + itglue_org_name: string | null; + datto_site_id: number | null; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ companyId: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { companyId } = await params; + const res = await postgresClient.query( + `SELECT c.id::text AS id, + c.company_name, + o.id::text AS itglue_org_id, + o.name AS itglue_org_name, + ds.id AS datto_site_id + FROM companies c + LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name) + LEFT JOIN datto_rmm_sites ds ON ds.autotask_company_id = c.id + WHERE c.id = $1 + LIMIT 1`, + [companyId] + ); + if (res.rowCount === 0) { + return NextResponse.json({ error: 'Company not found' }, { status: 404 }); + } + const c = res.rows[0]; + const target = await resolveSiteAnchorTarget(companyId); + const allTargets = await listSiteAnchorTargets(companyId); + return NextResponse.json({ + site: { + companyId: c.id, + companyName: c.company_name, + itglueOrgId: c.itglue_org_id, + itglueOrgName: c.itglue_org_name, + dattoSiteId: c.datto_site_id, + // The chosen primary target — what the executor picks by default. + wnpHostname: target?.hostname ?? null, + wnpDeviceUid: target?.device_uid ?? null, + wnpOnline: target?.online ?? null, + // Every available WNP across all sites for the client. + sites: allTargets, + }, + }); +} diff --git a/app/api/analyzer/itglue/writes/route.ts b/app/api/analyzer/itglue/writes/route.ts new file mode 100644 index 0000000..b12020c --- /dev/null +++ b/app/api/analyzer/itglue/writes/route.ts @@ -0,0 +1,39 @@ +/** + * GET /api/analyzer/itglue/writes + * + * Cross-asset write history — admin only. Powers /admin/itglue-writes. + * + * Query params: ?status=...&limit=...&offset=... + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import { + listAllWrites, + type WriteStatus, +} from '@/lib/services/analyzer/asset-audit/persistence'; + +const ALLOWED_STATUSES: ReadonlyArray = [ + 'pending', + 'committed', + 'failed', + 'reverted', +]; + +export async function GET(request: NextRequest) { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit') ?? 100); + const offset = Number(url.searchParams.get('offset') ?? 0); + const statusParam = url.searchParams.get('status'); + const status = ( + statusParam && ALLOWED_STATUSES.includes(statusParam as WriteStatus) + ? (statusParam as WriteStatus) + : undefined + ); + + const writes = await listAllWrites({ limit, offset, status }); + return NextResponse.json({ writes }); +} diff --git a/app/api/analyzer/share/recipients/route.ts b/app/api/analyzer/share/recipients/route.ts new file mode 100644 index 0000000..a0a0cb2 --- /dev/null +++ b/app/api/analyzer/share/recipients/route.ts @@ -0,0 +1,105 @@ +/** + * GET /api/analyzer/share/recipients + * + * Returns suggestions for the share modal: + * - recent: the last few unique recipient emails the calling user has + * shared with (from analyzer_shares). + * - directory: the AD/Microsoft Graph user directory, filtered to + * ALLOWED_SHARE_DOMAINS and active accounts only. + * + * The share endpoint itself still validates the domain server-side, so + * the directory list is a UX nicety, not a security boundary. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +const RECENT_LIMIT = 5; + +function getAllowedDomains(): string[] { + const raw = process.env.ALLOWED_SHARE_DOMAINS ?? ''; + return raw + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); +} + +interface RecentRow { + shared_with_email: string; + last_shared_at: Date | string; +} + +interface DirectoryRow { + email: string; + display_name: string | null; + job_title: string | null; + department: string | null; +} + +function toIso(d: Date | string): string { + return d instanceof Date ? d.toISOString() : new Date(d).toISOString(); +} + +function emailDomain(email: string): string { + return email.split('@')[1]?.toLowerCase() ?? ''; +} + +export async function GET(_request: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + const allowedDomains = getAllowedDomains(); + + if (allowedDomains.length === 0) { + return NextResponse.json({ + recent: [], + directory: [], + allowedDomains: [], + }); + } + + const recentRows = userId + ? ( + await postgresClient.query( + `SELECT shared_with_email, + MAX(shared_at) AS last_shared_at + FROM analyzer_shares + WHERE shared_by_user_id = $1 + GROUP BY shared_with_email + ORDER BY MAX(shared_at) DESC + LIMIT $2`, + [userId, RECENT_LIMIT] + ) + ).rows + : []; + + const directoryRes = await postgresClient.query( + `SELECT email, display_name, job_title, department + FROM graph_users + WHERE account_enabled = true + AND email IS NOT NULL + AND lower(split_part(email, '@', 2)) = ANY($1::text[]) + ORDER BY COALESCE(display_name, email)`, + [allowedDomains] + ); + + // Drop recents whose domain is no longer allowed (defensive — share endpoint + // would reject them anyway, but the picker shouldn't dangle). + const recent = recentRows + .filter((r) => allowedDomains.includes(emailDomain(r.shared_with_email))) + .map((r) => ({ + email: r.shared_with_email, + lastSharedAt: toIso(r.last_shared_at), + })); + + const directory = directoryRes.rows.map((d) => ({ + email: d.email, + displayName: d.display_name, + jobTitle: d.job_title, + department: d.department, + })); + + return NextResponse.json({ recent, directory, allowedDomains }); +} diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts new file mode 100644 index 0000000..c961de7 --- /dev/null +++ b/app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts @@ -0,0 +1,257 @@ +/** + * 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, + }); +} diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts index 0b7309d..39b7e6e 100644 --- a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts +++ b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts @@ -37,7 +37,7 @@ export async function POST( const { ticketNumber } = await params; - let parsedBody: { force?: boolean }; + let parsedBody: { force?: boolean; provider?: 'anthropic' | 'openrouter' }; try { const body = await request.json().catch(() => ({})); const result = AnalyzeTicketRequest.safeParse(body); @@ -51,6 +51,7 @@ export async function POST( } catch { parsedBody = {}; } + const provider = parsedBody.provider ?? 'anthropic'; // Verify the ticket exists and load its data for the idempotency check. let bundle; @@ -74,12 +75,14 @@ export async function POST( } // Idempotency short-circuit: when force=false, return the existing analysis - // without queueing a job if the source data hasn't changed since. + // without queueing a job if the source data hasn't changed since. Provider- + // scoped so a Claude run never short-circuits a DeepSeek request. if (!parsedBody.force) { const pre = preprocessTicket(bundle); const existing = await findExistingAnalysisByContentHash( ticketNumber, - pre.content_hash + pre.content_hash, + provider ); if (existing) { return NextResponse.json({ @@ -96,6 +99,7 @@ export async function POST( const job = await queueJob({ ticket_number: ticketNumber, queued_by_user_id: userId, + provider, }); return NextResponse.json({ diff --git a/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts b/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts new file mode 100644 index 0000000..107f53c --- /dev/null +++ b/app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts @@ -0,0 +1,21 @@ +/** + * GET /api/analyzer/tickets/:ticketNumber/itglue-xrefs + * + * Returns the xrefs for one ticket — "every IT Glue asset this ticket + * touched" — for use on the analysis page or future ticket views. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listXrefsForTicket } from '@/lib/services/analyzer/asset-audit/xrefs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ ticketNumber: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { ticketNumber } = await params; + const xrefs = await listXrefsForTicket(ticketNumber, 100); + return NextResponse.json({ xrefs }); +} diff --git a/app/api/analyzer/tickets/[ticketNumber]/links/route.ts b/app/api/analyzer/tickets/[ticketNumber]/links/route.ts new file mode 100644 index 0000000..2888363 --- /dev/null +++ b/app/api/analyzer/tickets/[ticketNumber]/links/route.ts @@ -0,0 +1,97 @@ +/** + * GET /api/analyzer/tickets/:ticketNumber/links + * Cheap explicit-only discovery (regex + RELATED TICKETS section + problem_ticket_id). + * No LLM cost. Use this on page load to render the Related Tickets panel. + * + * POST /api/analyzer/tickets/:ticketNumber/links + * Body: { includeSuggested?: boolean } + * Runs the Haiku-suggested arm in addition to the explicit arm. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { + loadTicketBundle, + TicketNotFoundError, +} from '@/lib/services/analyzer/data-access'; +import { + discoverLinks, + discoverExplicitLinks, +} from '@/lib/services/analyzer/link-discovery'; +import { SuggestLinksRequest } from '@/lib/types/analyzer'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ ticketNumber: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { ticketNumber } = await params; + + let bundle; + try { + bundle = await loadTicketBundle(ticketNumber); + } catch (err) { + if (err instanceof TicketNotFoundError) { + return NextResponse.json( + { error: `Ticket ${ticketNumber} not found in local mirror` }, + { status: 404 } + ); + } + console.error('[analyzer/links] data-access error:', err); + return NextResponse.json( + { error: 'Failed to load ticket' }, + { status: 500 } + ); + } + + const result = await discoverExplicitLinks(bundle); + return NextResponse.json({ + explicit: result.explicit, + suggested: [], + isProblemTicket: result.isProblemTicket, + problemTicketSignals: result.problemTicketSignals, + }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ ticketNumber: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { ticketNumber } = await params; + + const body = await request.json().catch(() => ({})); + const parsed = SuggestLinksRequest.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.issues }, + { status: 400 } + ); + } + + let bundle; + try { + bundle = await loadTicketBundle(ticketNumber); + } catch (err) { + if (err instanceof TicketNotFoundError) { + return NextResponse.json( + { error: `Ticket ${ticketNumber} not found in local mirror` }, + { status: 404 } + ); + } + console.error('[analyzer/links] data-access error:', err); + return NextResponse.json( + { error: 'Failed to load ticket' }, + { status: 500 } + ); + } + + const result = await discoverLinks(bundle, { + includeSuggested: parsed.data.includeSuggested, + }); + return NextResponse.json(result); +} diff --git a/app/api/dashboard/integration-health/route.ts b/app/api/dashboard/integration-health/route.ts new file mode 100644 index 0000000..07ef02a --- /dev/null +++ b/app/api/dashboard/integration-health/route.ts @@ -0,0 +1,31 @@ +/** + * GET /api/dashboard/integration-health + * Live auth check + JWT expiry decode for each configured integration. + * Cached in-process for 5 minutes. + * + * ?refresh=1 forces re-check (admin only). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requirePermission } from '@/lib/auth-utils'; +import { + checkIntegrationHealth, + clearIntegrationHealthCache, + summarize, +} from '@/lib/services/integration-health'; + +export async function GET(request: NextRequest) { + const refresh = request.nextUrl.searchParams.get('refresh') === '1'; + + if (refresh) { + const adminCheck = await requirePermission('admin', 'access'); + if (adminCheck.error) return adminCheck.error; + clearIntegrationHealthCache(); + } else { + const authCheck = await requireAuth(); + if (authCheck.error) return authCheck.error; + } + + const items = await checkIntegrationHealth({ skipCache: refresh }); + return NextResponse.json({ items, summary: summarize(items) }); +} diff --git a/app/api/dashboard/overview/route.ts b/app/api/dashboard/overview/route.ts new file mode 100644 index 0000000..4497da0 --- /dev/null +++ b/app/api/dashboard/overview/route.ts @@ -0,0 +1,168 @@ +/** + * GET /api/dashboard/overview + * Single round-trip backing the new dashboard. All queries run in parallel. + * + * attention — counts that should pull a human's eyes + * observations — recent device_observations (loglift et al.) + * audits — recent endpoint_audits + * syncHealth — per-schedule last_run / last_status from sync_schedules + * stats — small footer: companies, CIs, xref linkage + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + + type Counts = { count: string }; + + const [ + linkConflictsRes, + itglueUnlinkedRes, + s1UnmappedRes, + schedulesRes, + observationsRes, + auditsRes, + syncHealthRes, + companiesRes, + ciRes, + xrefRes, + ] = await Promise.all([ + postgresClient.query( + `SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL` + ), + postgresClient.query( + `SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 'itglue' AND configuration_item_id IS NULL` + ), + postgresClient.query( + `SELECT COUNT(*)::text AS count FROM device_external_ids WHERE source = 's1' AND configuration_item_id IS NULL` + ), + postgresClient.query<{ enabled: string; total: string }>( + `SELECT + COUNT(*) FILTER (WHERE is_enabled)::text AS enabled, + COUNT(*)::text AS total + FROM sync_schedules` + ), + postgresClient.query<{ + id: string; + kind: string; + source: string; + collected_at: string; + hostname: string | null; + company_name: string | null; + run_id: string | null; + }>( + `SELECT o.id::text, + o.kind, o.source, + o.collected_at::text, + ci.reference_title AS hostname, + c.company_name, + o.run_id + FROM device_observations o + LEFT JOIN configuration_items ci ON ci.id = o.configuration_item_id + LEFT JOIN companies c ON c.id = ci.company_id + ORDER BY o.collected_at DESC + LIMIT 10` + ), + postgresClient.query<{ + id: string; + generated_at: string; + hostname: string | null; + company_name: string | null; + overall_score: string | null; + field_gaps_count: string; + status: string; + }>( + `SELECT a.id::text, + a.generated_at::text, + ci.reference_title AS hostname, + c.company_name, + a.overall_score::text, + jsonb_array_length(COALESCE(a.field_gaps, '[]'::jsonb))::text AS field_gaps_count, + a.status + FROM endpoint_audits a + LEFT JOIN configuration_items ci ON ci.id = a.configuration_item_id + LEFT JOIN companies c ON c.id = ci.company_id + ORDER BY a.generated_at DESC + LIMIT 10` + ), + postgresClient.query<{ + id: string; + name: string; + sync_type: string; + is_enabled: boolean; + last_run: string | null; + last_status: string | null; + last_error: string | null; + next_run: string | null; + }>( + `SELECT id, name, sync_type, is_enabled, + last_run::text, last_status, last_error, + next_run::text + FROM sync_schedules + ORDER BY name` + ), + postgresClient.query( + `SELECT COUNT(*)::text AS count FROM companies WHERE company_type = 1 AND is_active = true` + ), + postgresClient.query( + `SELECT COUNT(*)::text AS count FROM configuration_items WHERE is_deleted = false OR is_deleted IS NULL` + ), + postgresClient.query<{ total: string; linked: string }>( + `SELECT COUNT(*)::text AS total, + COUNT(*) FILTER (WHERE configuration_item_id IS NOT NULL)::text AS linked + FROM device_external_ids` + ), + ]); + + return NextResponse.json({ + attention: { + linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10), + itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10), + s1Unmapped: parseInt(s1UnmappedRes.rows[0]?.count ?? '0', 10), + schedules: { + enabled: parseInt(schedulesRes.rows[0]?.enabled ?? '0', 10), + total: parseInt(schedulesRes.rows[0]?.total ?? '0', 10), + }, + }, + observations: observationsRes.rows.map((r) => ({ + id: r.id, + kind: r.kind, + source: r.source, + collectedAt: r.collected_at, + hostname: r.hostname, + companyName: r.company_name, + runId: r.run_id, + })), + audits: auditsRes.rows.map((r) => ({ + id: r.id, + generatedAt: r.generated_at, + hostname: r.hostname, + companyName: r.company_name, + overallScore: r.overall_score === null ? null : Number(r.overall_score), + fieldGapsCount: parseInt(r.field_gaps_count, 10), + status: r.status, + })), + syncHealth: syncHealthRes.rows.map((r) => ({ + id: r.id, + name: r.name, + syncType: r.sync_type, + isEnabled: r.is_enabled, + lastRun: r.last_run, + lastStatus: r.last_status, + lastError: r.last_error, + nextRun: r.next_run, + })), + stats: { + activeCompanies: parseInt(companiesRes.rows[0]?.count ?? '0', 10), + configurationItems: parseInt(ciRes.rows[0]?.count ?? '0', 10), + xref: { + total: parseInt(xrefRes.rows[0]?.total ?? '0', 10), + linked: parseInt(xrefRes.rows[0]?.linked ?? '0', 10), + }, + }, + }); +} diff --git a/app/api/rmm/executions/[id]/route.ts b/app/api/rmm/executions/[id]/route.ts new file mode 100644 index 0000000..b520b85 --- /dev/null +++ b/app/api/rmm/executions/[id]/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/rmm/executions/:id + * + * Returns one execution row including stdout / stderr / parsed_evidence. + * Used by the live-stream UI to poll status. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { getExecutionById } from '@/lib/services/rmm/persistence'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + const { id } = await params; + const execution = await getExecutionById(id); + if (!execution) { + return NextResponse.json({ error: 'Execution not found' }, { status: 404 }); + } + return NextResponse.json({ execution }); +} diff --git a/app/api/rmm/executions/route.ts b/app/api/rmm/executions/route.ts new file mode 100644 index 0000000..1826676 --- /dev/null +++ b/app/api/rmm/executions/route.ts @@ -0,0 +1,118 @@ +/** + * GET /api/rmm/executions + * List recent executions. Filters: companyId, scriptId, status, + * assetType, assetId. requireAuth() — admin sees all by default. + * + * POST /api/rmm/executions + * Body: { scriptId, target: { type: 'site_anchor', companyId } | + * { type: 'asset_self', deviceUid, hostname?, companyId?, assetType?, assetId? }, + * triggeredByAuditId? } + * Requires rmm.execute. Queues a fresh execution. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAuth, requirePermission } from '@/lib/auth-utils'; +import { + listExecutions, + type RmmExecutionStatus, +} from '@/lib/services/rmm/persistence'; +import { queueExecution } from '@/lib/services/rmm/executor'; +// Side-effect import: starts the worker once per process. +import '@/lib/services/rmm/worker'; + +const PostBody = z.object({ + scriptId: z.string().min(1), + target: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('site_anchor'), + companyId: z.union([z.string(), z.number()]), + }), + z.object({ + type: z.literal('asset_self'), + deviceUid: z.string().min(1), + hostname: z.string().nullable().optional(), + companyId: z.union([z.string(), z.number()]).nullable().optional(), + assetType: z.enum(['flexible_asset', 'configuration']).optional(), + assetId: z.union([z.string(), z.number()]).optional(), + }), + ]), + triggeredByAuditId: z.string().uuid().nullable().optional(), +}); + +const ALLOWED_STATUSES: ReadonlyArray = [ + 'queued', + 'running', + 'complete', + 'failed', + 'timeout', +]; + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit') ?? 100); + const offset = Number(url.searchParams.get('offset') ?? 0); + const companyIdParam = url.searchParams.get('companyId'); + const scriptIdParam = url.searchParams.get('scriptId'); + const statusParam = url.searchParams.get('status'); + const assetTypeParam = url.searchParams.get('assetType'); + const assetIdParam = url.searchParams.get('assetId'); + const status = + statusParam && ALLOWED_STATUSES.includes(statusParam as RmmExecutionStatus) + ? (statusParam as RmmExecutionStatus) + : undefined; + const assetType = + assetTypeParam === 'flexible_asset' || assetTypeParam === 'configuration' + ? assetTypeParam + : undefined; + + const executions = await listExecutions({ + limit, + offset, + companyId: companyIdParam ?? undefined, + scriptId: scriptIdParam ?? undefined, + status, + assetType, + assetId: assetIdParam ?? undefined, + }); + return NextResponse.json({ executions }); +} + +export async function POST(request: NextRequest) { + const { session, error } = await requirePermission('rmm', 'execute'); + if (error) return error; + const userId = (session?.user as { id: string } | undefined)?.id ?? null; + + const body = await request.json().catch(() => ({})); + const parsed = PostBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid body', details: parsed.error.issues }, + { status: 400 } + ); + } + + try { + const result = await queueExecution({ + scriptId: parsed.data.scriptId, + target: parsed.data.target, + performedByUserId: userId, + triggeredByAuditId: parsed.data.triggeredByAuditId ?? null, + }); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Rate-limit failures + bad targets read as 400; everything else 500. + const isClient = + message.includes('rate limit') || + message.includes('No Wulf Nurse') || + message.includes('Unknown script') || + message.includes('expects target_type'); + return NextResponse.json( + { error: 'Could not queue execution', message }, + { status: isClient ? 400 : 500 } + ); + } +} diff --git a/app/api/rmm/loglift/upload/route.ts b/app/api/rmm/loglift/upload/route.ts new file mode 100644 index 0000000..9a83217 --- /dev/null +++ b/app/api/rmm/loglift/upload/route.ts @@ -0,0 +1,99 @@ +/** + * LogLift evidence webhook. + * + * The Datto RMM collector component uploads gzipped event-log JSON to B2, + * then POSTs the metadata here. Pulse downloads the gzip, slims it, persists + * it as RMM evidence, and (when the hostname matches a single IT Glue + * Configuration) fires an asset-first audit on the side. + * + * Auth: `x-openclaw-key` header — same shared secret the collector already + * carries for OpenClaw API calls. + * + * Public route per `middleware.ts` (`/api/rmm/loglift` exclusion). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { OBJECT_KEY_REGEX } from '@/lib/services/b2/client'; +import { processLogliftWebhook } from '@/lib/services/rmm/loglift-receiver'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const WebhookSchema = z.object({ + runId: z.string().min(1).max(200), + clientId: z.string().min(1).max(200), + computerName: z.string().min(1).max(200), + deviceUid: z.string().max(200).nullable().optional(), + summary: z.object({ + totalEvents: z.number().int().nonnegative().nullable().optional(), + criticalEvents: z.number().int().nonnegative().nullable().optional(), + errorCount: z.number().int().nonnegative().nullable().optional(), + warningCount: z.number().int().nonnegative().nullable().optional(), + timeRange: z.string().max(200).nullable().optional(), + }), + objectKey: z + .string() + .min(1) + .max(500) + .refine((s) => OBJECT_KEY_REGEX.test(s), { + message: 'objectKey does not match the required eventlogs path shape', + }), + collectedAt: z.string().min(1).max(64), + rmmContext: z + .object({ + siteName: z.string().max(200).nullable().optional(), + siteUid: z.string().max(200).nullable().optional(), + accountUid: z.string().max(200).nullable().optional(), + }) + .partial() + .nullable() + .optional(), + issueDescription: z.string().max(4000).nullable().optional(), + ticketNumber: z.string().max(64).nullable().optional(), +}); + +export async function POST(req: NextRequest) { + const auth = validateOpenClawKey(req); + if (auth) return auth; + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const parsed = WebhookSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { + error: 'Invalid payload', + details: parsed.error.flatten(), + }, + { status: 400 } + ); + } + + try { + const result = await processLogliftWebhook(parsed.data); + return NextResponse.json( + { + executionId: result.executionId, + matched: result.matched, + parsed: result.parsed, + auditId: result.audit_id, + }, + { status: 200 } + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[LOGLIFT-WEBHOOK]', message, err); + return NextResponse.json( + { error: 'Failed to process LogLift webhook', message }, + { status: 500 } + ); + } +} diff --git a/app/api/rmm/scripts/route.ts b/app/api/rmm/scripts/route.ts new file mode 100644 index 0000000..072a1a6 --- /dev/null +++ b/app/api/rmm/scripts/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/rmm/scripts + * + * Returns the script library catalog (id, name, description, target_type, + * expected_runtime_seconds, version). Bodies are not returned — they live + * in the repo and are only sent to Datto RMM, never to clients. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listScripts } from '@/lib/services/rmm/scripts'; + +export async function GET(_request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + const scripts = listScripts().map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + target_type: s.target_type, + expected_runtime_seconds: s.expected_runtime_seconds, + version: s.version, + })); + return NextResponse.json({ scripts }); +} diff --git a/app/api/sync/schedules/reload/route.ts b/app/api/sync/schedules/reload/route.ts new file mode 100644 index 0000000..1ab43ef --- /dev/null +++ b/app/api/sync/schedules/reload/route.ts @@ -0,0 +1,27 @@ +/** + * POST /api/sync/schedules/reload + * Stops every running cron task and re-loads from the DB. Use after seeding + * new schedule rows directly via SQL (the scheduler only seeds defaults on a + * virgin table). + */ + +import { NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import { syncScheduler } from '@/lib/services/sync-scheduler'; + +export async function POST() { + const { error } = await requirePermission('admin', 'access'); + if (error) return error; + + try { + const result = await syncScheduler.reloadAllSchedules(); + return NextResponse.json({ ok: true, ...result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[SCHEDULE API] reload failed:', message); + return NextResponse.json( + { error: 'Failed to reload schedules', details: message }, + { status: 500 } + ); + } +} diff --git a/app/configuration-items/page.tsx b/app/configuration-items/page.tsx index 7bc1ead..4ac1b8b 100644 --- a/app/configuration-items/page.tsx +++ b/app/configuration-items/page.tsx @@ -76,6 +76,7 @@ import { DattoRMMDevice } from '@/lib/types/datto-rmm'; import { AuvikDevice } from '@/lib/types/auvik'; import { AddigyDevice } from '@/lib/types/addigy'; import { useApi } from '@/lib/hooks/use-api'; +import { RmmDispatchDialog } from '@/components/rmm/rmm-dispatch-dialog'; interface DeviceComparison { autotaskDevice?: ConfigurationItem; @@ -864,6 +865,7 @@ function ConfigurationItemsContent() { RMM NMS ARMM + Actions @@ -889,7 +891,7 @@ function ConfigurationItemsContent() { setExpandedContacts(newExpanded); }} > - +
@@ -1024,6 +1026,17 @@ function ConfigurationItemsContent() { )} + e.stopPropagation()}> + {item.rmmDevice?.uid ? ( + + ) : ( + + )} + ))} @@ -1150,6 +1163,17 @@ function ConfigurationItemsContent() { )} + e.stopPropagation()}> + {item.rmmDevice?.uid ? ( + + ) : ( + + )} + )) )} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 5797415..503a15a 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -2,386 +2,453 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Progress } from '@/components/ui/progress'; -import { - Server, - Building2, - Network, - Globe, - Smartphone, +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertTriangle, Database, + Shield, + CalendarClock, RefreshCw, ArrowRight, - Activity, - TrendingUp, - AlertCircle, - CheckCircle, + CheckCircle2, XCircle, - Users, - HardDrive, - Wifi, - FileText + Clock, + Activity, + Sparkles, + Plug, + KeyRound, } from 'lucide-react'; -interface DashboardStats { - companies: { - total: number; - active: number; - }; - configurationItems: { - total: number; - active: number; - }; - mappings: { - auvik: { - mapped: number; - unmapped: number; - }; - rmm: { - mapped: number; - unmapped: number; - }; - }; - quotes: { - open: number; +interface IntegrationHealthItem { + key: string; + name: string; + category: string; + status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown'; + configured: boolean; + latencyMs?: number; + error?: string | null; + tokenExpiry?: { + envVar: string; + expiresAt: string; + daysRemaining: number; + subject?: string | null; + } | null; + checkedAt: string; +} + +interface IntegrationHealthResponse { + items: IntegrationHealthItem[]; + summary: { total: number; + ok: number; + failed: number; + notConfigured: number; + expiringWithin14Days: number; + expired: number; + hasIssues: boolean; }; } +interface Overview { + attention: { + linkConflicts: number; + itglueUnlinked: number; + s1Unmapped: number; + schedules: { enabled: number; total: number }; + }; + observations: Array<{ + id: string; + kind: string; + source: string; + collectedAt: string; + hostname: string | null; + companyName: string | null; + runId: string | null; + }>; + audits: Array<{ + id: string; + generatedAt: string; + hostname: string | null; + companyName: string | null; + overallScore: number | null; + fieldGapsCount: number; + status: string; + }>; + syncHealth: Array<{ + id: string; + name: string; + syncType: string; + isEnabled: boolean; + lastRun: string | null; + lastStatus: string | null; + lastError: string | null; + nextRun: string | null; + }>; + stats: { + activeCompanies: number; + configurationItems: number; + xref: { total: number; linked: number }; + }; +} + +const STALE_HOURS = 24; + +function relTime(iso: string | null): string { + if (!iso) return 'never'; + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 0) return 'in the future'; + const min = Math.floor(ms / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min} min ago`; + const hr = Math.floor(min / 60); + if (hr < 48) return `${hr} h ago`; + const day = Math.floor(hr / 24); + return `${day} d ago`; +} + +function isStale(iso: string | null): boolean { + if (!iso) return true; + return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000; +} + +function syncStatusIcon(s: { lastStatus: string | null; lastRun: string | null; isEnabled: boolean }) { + if (!s.isEnabled) return off; + if (s.lastStatus === 'failed') + return ; + if (isStale(s.lastRun)) + return ; + if (s.lastStatus === 'success') + return ; + return ; +} + export default function DashboardPage() { - const [stats, setStats] = useState({ - companies: { total: 0, active: 0 }, - configurationItems: { total: 0, active: 0 }, - mappings: { - auvik: { mapped: 0, unmapped: 0 }, - rmm: { mapped: 0, unmapped: 0 } - }, - quotes: { open: 0, total: 0 } - }); - const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [health, setHealth] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); - useEffect(() => { - fetchStats(); - }, []); - - const fetchStats = async () => { + async function load(): Promise { + setLoading(true); try { - // Fetch cached stats from database (fast, no external API calls) - const statsRes = await fetch('/api/dashboard/stats'); - const statsData = await statsRes.json(); - - setStats({ - companies: statsData.companies || { total: 0, active: 0 }, - configurationItems: { - total: 0, - active: 0 - }, - mappings: statsData.mappings || { - auvik: { mapped: 0, unmapped: 0 }, - rmm: { mapped: 0, unmapped: 0 } - }, - quotes: statsData.quotes || { open: 0, total: 0 } - }); - } catch (error) { - console.error('Error fetching dashboard stats:', error); + const [overviewRes, healthRes] = await Promise.all([ + fetch('/api/dashboard/overview'), + fetch('/api/dashboard/integration-health'), + ]); + if (!overviewRes.ok) { + const body = (await overviewRes.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `HTTP ${overviewRes.status}`); + } + setData((await overviewRes.json()) as Overview); + if (healthRes.ok) { + setHealth((await healthRes.json()) as IntegrationHealthResponse); + } + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); } - }; + } - const quickLinks = [ - { - title: 'Configuration Items', - description: 'View and manage IT assets and devices', - href: '/configuration-items', - icon: Server, - color: 'blue', - stats: `${stats.configurationItems.active} active items` - }, - { - title: 'Kiosk Display', - description: 'Configure and view executive dashboard for TV', - href: '/kiosk/settings', - icon: Activity, - color: 'blue', - stats: 'Settings & display' - }, - { - title: 'Sync Management', - description: 'Synchronize data from external systems', - href: '/admin/sync', - icon: RefreshCw, - color: 'green', - stats: 'Run data synchronization' - }, - { - title: 'Data Browser', - description: 'Browse and query system data', - href: '/admin/data-browser', - icon: Database, - color: 'purple', - stats: 'Explore database tables' - } - ]; - - const mappingCards = [ - { - title: 'NMS Mapping (Auvik)', - description: 'Network Management System integration', - href: '/auvik-mappings', - icon: Network, - color: 'blue', - mapped: stats.mappings.auvik.mapped, - unmapped: stats.mappings.auvik.unmapped, - total: stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped - }, - { - title: 'RMM Mapping (Datto)', - description: 'Remote Monitoring & Management', - href: '/rmm-mappings', - icon: Globe, - color: 'purple', - mapped: stats.mappings.rmm.mapped, - unmapped: stats.mappings.rmm.unmapped, - total: stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped - }, - { - title: 'Apple RMM (Addigy)', - description: 'Apple device management', - href: '/addigy-mappings', - icon: Smartphone, - color: 'orange', - mapped: 0, - unmapped: 0, - total: 0, - comingSoon: true - } - ]; - - const getMappingProgress = (mapped: number, total: number) => { - if (total === 0) return 0; - return (mapped / total) * 100; - }; + useEffect(() => { + void load(); + }, []); return ( -
- {/* Header */} +
-
-

Dashboard

-

- Welcome to Pulse - Your PSA Management System -

-
-
- {/* Stats Overview */} -
- - - Total Companies - - - -
{stats.companies.total}
-

- {stats.companies.active} active -

-
-
- - - - NMS Coverage - - - -
- {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped > 0 - ? Math.round(getMappingProgress(stats.mappings.auvik.mapped, stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped)) - : 0}% -
-

- {stats.mappings.auvik.mapped} of {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped} tenants -

-
-
- - - - RMM Coverage - - - -
- {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped > 0 - ? Math.round(getMappingProgress(stats.mappings.rmm.mapped, stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped)) - : 0}% -
-

- {stats.mappings.rmm.mapped} of {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped} sites -

-
-
- - - - - Open Quotes - - - -
{stats.quotes.open}
-

- Pending approval -

-
-
- - - - - System Status - - - -
- - Online -
-

- All systems operational -

-
-
-
+ {error && ( + + Failed to load + {error} + + )} - {/* Quick Links */} -
-

Quick Access

-
- {quickLinks.map((link) => ( - - - -
- - -
- {link.title} - {link.description} -
- -

{link.stats}

-
-
- - ))} + {/* NEEDS ATTENTION ----------------------------------------------------- */} +
+

+ Needs attention +

+
+ 0 ? 'warn' : 'ok'} + /> + + +
-
+ - {/* Mapping Status */} -
-

Integration Mappings

-
- {mappingCards.map((mapping) => ( - - {mapping.comingSoon && ( - - Coming Soon - - )} - -
- - {!mapping.comingSoon && mapping.unmapped > 0 && ( - - - {mapping.unmapped} unmapped - - )} -
- {mapping.title} - {mapping.description} -
- - {!mapping.comingSoon ? ( - <> -
-
- Coverage - - {Math.round(getMappingProgress(mapping.mapped, mapping.total))}% - + {/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */} +
+ + + + + Recent device observations + + + + {data === null && !error ? ( + + ) : data?.observations.length === 0 ? ( +

No observations recorded yet.

+ ) : ( +
+ {data?.observations.map((o) => ( +
+
+
{o.hostname ?? '(unanchored)'}
+
+ {o.kind} + {o.companyName && · {o.companyName}}
-
-
- - - {mapping.mapped} mapped - - - - {mapping.unmapped} unmapped - +
+ {relTime(o.collectedAt)}
- - - - - ) : ( -

- Integration under development -

- )} - - - ))} -
-
- - {/* Recent Activity - Placeholder */} -
-

Recent Activity

- - -
-
-
-
-

Data sync completed

-

Companies synchronized successfully - 5 minutes ago

-
+
+ ))}
-
-
-
-

New RMM site mapped

-

Site "Acme Corp - Dallas" mapped to Acme Corp - 2 hours ago

-
-
-
-
-
-

Configuration items updated

-

247 devices synchronized from RMM - 1 day ago

-
-
-
+ )} + + + + + + Recent audits + + + + {data === null && !error ? ( + + ) : data?.audits.length === 0 ? ( +

No endpoint audits yet.

+ ) : ( +
+ {data?.audits.map((a) => ( +
+
+
{a.hostname ?? '(unanchored)'}
+
+ score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps + {a.companyName && · {a.companyName}} +
+
+
+ {relTime(a.generatedAt)} +
+
+ ))} +
+ )} +
+
+
+ + {/* INTEGRATION HEALTH -------------------------------------------------- */} + + + + + Integration health + {health?.summary.hasIssues && ( + issues + )} + + + + {!health ? ( + + ) : ( +
+ {health.items + .slice() + .sort((a, b) => statusOrder(a.status) - statusOrder(b.status)) + .map((i) => ( + + ))} +
+ )} +
+
+ + {/* SYNC HEALTH --------------------------------------------------------- */} + + + Sync health + + + {data === null && !error ? ( + + ) : ( +
+ {data?.syncHealth.map((s) => ( +
+
{s.name}
+
+ {relTime(s.lastRun)} + {syncStatusIcon(s)} +
+
+ ))} +
+ )} +
+
+ + {/* STATS FOOTER -------------------------------------------------------- */} + {data && ( +

+ {data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '} + {data.stats.xref.total.toLocaleString()} xref rows ( + {data.stats.xref.total > 0 + ? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100) + : 0} + % linked) +

+ )} +
+ ); +} + +function AttentionCard(props: { + icon: React.ElementType; + value: number | undefined; + label: string; + sub?: string; + href: string; + tone: 'ok' | 'warn' | 'info'; +}) { + const { icon: Icon, value, label, sub, href, tone } = props; + const valueColor = + tone === 'warn' && value && value > 0 + ? 'text-amber-600 dark:text-amber-500' + : tone === 'ok' + ? 'text-foreground' + : 'text-foreground'; + return ( + + + +
+ + +
+
+ {value === undefined ? '—' : value.toLocaleString()} +
+
{label}
+ {sub &&
{sub}
} +
+
+ + ); +} + +function statusOrder(s: IntegrationHealthItem['status']): number { + switch (s) { + case 'auth_failed': return 0; + case 'unreachable': return 1; + case 'unknown': return 2; + case 'ok': return 3; + case 'not_configured': return 4; + default: return 5; + } +} + +function statusBadge(item: IntegrationHealthItem) { + const expiringSoon = + item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14; + const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0; + if (item.status === 'auth_failed' || item.status === 'unreachable') + return ; + if (expired) + return ; + if (expiringSoon) + return ; + if (item.status === 'ok') + return ; + if (item.status === 'unknown') + return ; + return off; +} + +function IntegrationRow({ item }: { item: IntegrationHealthItem }) { + const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0; + const expiringSoon = + item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14; + const detail = + item.status === 'auth_failed' || item.status === 'unreachable' + ? item.error?.slice(0, 80) + : expired + ? `token expired ${Math.abs(item.tokenExpiry!.daysRemaining).toFixed(0)} d ago` + : expiringSoon + ? `token expires in ${item.tokenExpiry!.daysRemaining.toFixed(0)} d` + : item.latencyMs !== undefined + ? `${item.latencyMs} ms` + : null; + return ( +
+
{item.name}
+
+ {detail && {detail}} + {statusBadge(item)}
); } + +function RowSkeletons() { + return ( +
+ + + +
+ ); +} diff --git a/components/admin/SyncScheduler.tsx b/components/admin/SyncScheduler.tsx index 6360555..7143f87 100644 --- a/components/admin/SyncScheduler.tsx +++ b/components/admin/SyncScheduler.tsx @@ -10,7 +10,7 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; +import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'; interface ScheduleConfig { id: string; @@ -37,6 +37,7 @@ interface ScheduleStatus { export default function SyncScheduler() { const [schedules, setSchedules] = useState([]); + const [reloading, setReloading] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [editingSchedule, setEditingSchedule] = useState(null); @@ -90,6 +91,20 @@ export default function SyncScheduler() { } }; + const reloadSchedules = async () => { + setReloading(true); + try { + const res = await fetch('/api/sync/schedules/reload', { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + await fetchSchedules(); + } catch (err) { + alert(`Reload failed: ${err instanceof Error ? err.message : err}`); + } finally { + setReloading(false); + } + }; + const toggleSchedule = async (scheduleId: string, currentState: boolean) => { try { const response = await fetch(`/api/sync/schedules/${scheduleId}`, { @@ -259,10 +274,16 @@ export default function SyncScheduler() { Manage automatic sync schedules
- +
+ + +
diff --git a/components/analyzer/analyze-button.tsx b/components/analyzer/analyze-button.tsx index a462cb3..45d9b62 100644 --- a/components/analyzer/analyze-button.tsx +++ b/components/analyzer/analyze-button.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { Sparkles, Loader2 } from 'lucide-react'; import type { JobStatus } from '@/lib/types/analyzer'; +import type { AnalyzerProvider } from './provider-toggle'; interface AnalyzeButtonProps { ticketNumber: string; @@ -13,6 +14,8 @@ interface AnalyzeButtonProps { force?: boolean; variant?: 'default' | 'outline' | 'secondary'; label?: string; + /** LLM provider (anthropic = Claude default; openrouter = DeepSeek). */ + provider?: AnalyzerProvider; } const STAGE_LABEL: Record = { @@ -31,13 +34,16 @@ export function AnalyzeButton({ force = false, variant = 'default', label = 'Analyze', + provider = 'anthropic', }: AnalyzeButtonProps) { const router = useRouter(); const [status, setStatus] = useState('idle'); async function pollJob(jobId: string) { const start = Date.now(); - const TIMEOUT_MS = 5 * 60 * 1000; + // DeepSeek runs (especially V4 Pro deep analysis) take 4-6× longer than + // Claude — observed ~5min on a typical ticket. 12min keeps headroom. + const TIMEOUT_MS = 12 * 60 * 1000; while (Date.now() - start < TIMEOUT_MS) { await new Promise((r) => setTimeout(r, 2000)); const res = await fetch(`/api/analyzer/jobs/${jobId}`); @@ -64,7 +70,7 @@ export function AnalyzeButton({ { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ force }), + body: JSON.stringify({ force, provider }), } ); if (!res.ok) { diff --git a/components/analyzer/itglue-suggestions-panel.tsx b/components/analyzer/itglue-suggestions-panel.tsx new file mode 100644 index 0000000..abc4cd1 --- /dev/null +++ b/components/analyzer/itglue-suggestions-panel.tsx @@ -0,0 +1,493 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + ProviderToggle, + type AnalyzerProvider, +} from '@/components/analyzer/provider-toggle'; +import { + Sparkles, + Loader2, + ExternalLink, + CheckCircle2, + Server, + Layers, + Database, +} from 'lucide-react'; +import { useSession } from '@/lib/auth-client'; +import { toast } from 'sonner'; + +interface FieldGap { + field_name: string; + why_missing_matters: string; + suggested_value: string | null; + evidence_ticket_numbers: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +interface NotePromotion { + quoted_note_text: string; + target_field: string; + suggested_value: string; + confidence: 'high' | 'medium' | 'low'; +} + +interface AuditRow { + id: string; + generated_at: string; + provider: 'anthropic' | 'openrouter'; + ticket_count: number; + field_gaps: FieldGap[]; + notes_promotions: NotePromotion[]; + contradictions: { description: string; evidence: string }[]; + overall_score: number | null; + estimated_cost_usd: number | null; +} + +interface MatchedAsset { + id: string; + name: string | null; + hostname?: string | null; + type_name: string | null; + score: number; + matched_term: string; + latestAudit: AuditRow | null; +} + +interface SuggestionsResponse { + ticketNumber: string; + organizationId: string | null; + organizationName: string | null; + flexibleAssets: MatchedAsset[]; + configurations: MatchedAsset[]; +} + +const CONFIDENCE_TONE: Record = { + high: 'border-red-500 bg-red-500/10', + medium: 'border-amber-500 bg-amber-500/10', + low: 'border-blue-500 bg-blue-500/10', +}; + +interface ItglueSuggestionsPanelProps { + analysisId: string; +} + +export function ItglueSuggestionsPanel({ analysisId }: ItglueSuggestionsPanelProps) { + const { data: session } = useSession(); + const role = (session?.user as { role?: string } | undefined)?.role ?? 'user'; + const canWrite = role === 'admin' || role === 'super-admin'; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [provider, setProvider] = useState('anthropic'); + const [auditing, setAuditing] = useState(null); + const [busyKey, setBusyKey] = useState(null); + + async function loadSuggestions(): Promise { + setLoading(true); + setLoadError(null); + try { + const res = await fetch( + `/api/analyzer/analyses/${analysisId}/itglue-suggestions` + ); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(err.error ?? `Request failed: ${res.status}`); + } + const d = (await res.json()) as SuggestionsResponse; + setData(d); + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + + async function runAudit( + assetType: 'flexible_asset' | 'configuration', + assetId: string + ): Promise { + const key = `${assetType}:${assetId}`; + setAuditing(key); + try { + const res = await fetch( + `/api/analyzer/analyses/${analysisId}/itglue-suggestions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assetType, assetId, provider }), + } + ); + const d = await res.json(); + if (!res.ok) throw new Error(d.message || d.error || 'Audit failed'); + toast.success('Audit complete'); + // Refresh the suggestions to pick up the new audit row. + await loadSuggestions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Audit failed'); + } finally { + setAuditing(null); + } + } + + async function applyGap( + assetType: 'flexible_asset' | 'configuration', + assetId: string, + auditId: string, + gap: FieldGap | NotePromotion, + kind: 'field_gap' | 'note_promotion' + ): Promise { + if (!canWrite) return; + const fieldName = + kind === 'field_gap' ? (gap as FieldGap).field_name : (gap as NotePromotion).target_field; + const suggested = + kind === 'field_gap' + ? (gap as FieldGap).suggested_value + : (gap as NotePromotion).suggested_value; + if (suggested === null || suggested === undefined || suggested === '') { + toast.error('No suggested value to apply'); + return; + } + const evidence = + kind === 'field_gap' + ? { + ticket_numbers: (gap as FieldGap).evidence_ticket_numbers, + gap_description: (gap as FieldGap).why_missing_matters, + } + : { + ticket_numbers: [], + gap_description: `Promoted from Notes: "${(gap as NotePromotion).quoted_note_text}"`, + }; + const key = `${assetType}:${assetId}:${kind}:${fieldName}`; + setBusyKey(key); + try { + const path = + assetType === 'flexible_asset' + ? `/api/analyzer/itglue/applications/${assetId}/apply` + : `/api/analyzer/itglue/configurations/${assetId}/apply`; + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auditId, + fieldName, + suggestedValue: suggested, + sourceEvidence: evidence, + }), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.message || d.error || 'Apply failed'); + toast.success(`Applied: ${fieldName}`); + await loadSuggestions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Apply failed'); + } finally { + setBusyKey(null); + } + } + + const totalMatches = useMemo(() => { + if (!data) return 0; + return data.flexibleAssets.length + data.configurations.length; + }, [data]); + + return ( + + +
+
+ + IT Glue documentation +
+
+ + +
+
+
+ + {loadError && ( + + Couldn’t load suggestions + {loadError} + + )} + + {!data && !loading && !loadError && ( +

+ Click Check IT Glue documentation to find IT Glue + records this ticket touched and surface what should be documented. +

+ )} + + {data && ( + <> +

+ Matched {totalMatches} IT Glue record + {totalMatches === 1 ? '' : 's'} for{' '} + {data.organizationName ?? 'this client'}. + {totalMatches === 0 && + ' (No matches — ticket fingerprint did not mention any IT Glue assets we could find.)'} +

+ + {data.flexibleAssets.length > 0 && ( +
+

+ Applications ({data.flexibleAssets.length}) +

+ {data.flexibleAssets.map((m) => + renderAssetMatch( + m, + 'flexible_asset', + auditing === `flexible_asset:${m.id}`, + busyKey, + canWrite, + () => runAudit('flexible_asset', m.id), + (g, k, auditId) => applyGap('flexible_asset', m.id, auditId, g, k) + ) + )} +
+ )} + + {data.configurations.length > 0 && ( +
+

+ Configurations ({data.configurations.length}) +

+ {data.configurations.map((m) => + renderAssetMatch( + m, + 'configuration', + auditing === `configuration:${m.id}`, + busyKey, + canWrite, + () => runAudit('configuration', m.id), + (g, k, auditId) => applyGap('configuration', m.id, auditId, g, k) + ) + )} +
+ )} + + )} +
+
+ ); +} + +function renderAssetMatch( + m: MatchedAsset, + assetType: 'flexible_asset' | 'configuration', + auditing: boolean, + busyKey: string | null, + canWrite: boolean, + onRunAudit: () => void, + onApply: ( + gap: FieldGap | NotePromotion, + kind: 'field_gap' | 'note_promotion', + auditId: string + ) => void +) { + const detailHref = + assetType === 'flexible_asset' + ? `/analyzer/itglue/applications/${m.id}` + : `/analyzer/itglue/configurations/${m.id}`; + const audit = m.latestAudit; + return ( +
+
+
+ + {m.name ?? m.id} + +

+ {m.type_name ?? '—'} + {m.hostname && ` · ${m.hostname}`} + {' · '}match: {m.matched_term} + {audit?.overall_score !== null && audit?.overall_score !== undefined && ( + <> + {' · '}score{' '} + 0.8 + ? 'default' + : (audit.overall_score ?? 0) > 0.5 + ? 'secondary' + : 'destructive' + } + className="text-[10px]" + > + {Math.round((audit.overall_score ?? 0) * 100)}% + + + )} +

+
+
+ + +
+
+ + {audit && ( +
+ {audit.field_gaps.length === 0 && audit.notes_promotions.length === 0 && ( +

+ No new gaps surfaced from this ticket. Existing record looks + sufficient for what was learned. +

+ )} + {audit.field_gaps.map((g) => { + const k = `${assetType}:${m.id}:field_gap:${g.field_name}`; + const busy = busyKey === k; + return ( +
+
+
+

{g.field_name}

+

{g.why_missing_matters}

+ {g.suggested_value !== null && ( +

+ Suggested: + + {g.suggested_value} + +

+ )} +
+
+ + {g.confidence} + + +
+
+
+ ); + })} + {audit.notes_promotions.map((p, i) => { + const k = `${assetType}:${m.id}:note_promotion:${p.target_field}:${i}`; + const busy = busyKey === `${assetType}:${m.id}:note_promotion:${p.target_field}`; + return ( +
+
+
+

+ “{p.quoted_note_text}” +

+

+ → {p.target_field}:{' '} + {p.suggested_value} +

+
+
+ + {p.confidence} + + +
+
+
+ ); + })} + {audit.contradictions.length > 0 && ( +
+ {audit.contradictions.map((c, i) => ( +

+ ⚠ {c.description} + — {c.evidence} +

+ ))} +
+ )} +
+ )} +
+ ); +} + +interface MatchedAssetExt extends MatchedAsset { + hostname?: string | null; +} +void ({} as MatchedAssetExt); diff --git a/components/analyzer/provider-toggle.tsx b/components/analyzer/provider-toggle.tsx new file mode 100644 index 0000000..ae60968 --- /dev/null +++ b/components/analyzer/provider-toggle.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Sparkles, Zap } from 'lucide-react'; + +export type AnalyzerProvider = 'anthropic' | 'openrouter'; + +interface ProviderToggleProps { + value: AnalyzerProvider; + onChange: (next: AnalyzerProvider) => void; + disabled?: boolean; + size?: 'sm' | 'md'; +} + +const OPTIONS: Array<{ + value: AnalyzerProvider; + label: string; + hint: string; + icon: typeof Sparkles; +}> = [ + { + value: 'anthropic', + label: 'Claude', + hint: 'Haiku → Sonnet → Opus', + icon: Sparkles, + }, + { + value: 'openrouter', + label: 'DeepSeek', + hint: 'V4 Flash → V4 Pro → R1', + icon: Zap, + }, +]; + +export function ProviderToggle({ + value, + onChange, + disabled = false, + size = 'md', +}: ProviderToggleProps) { + const padding = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'; + return ( +
+ {OPTIONS.map((opt) => { + const Icon = opt.icon; + const active = opt.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/components/analyzer/related-tickets-panel.tsx b/components/analyzer/related-tickets-panel.tsx new file mode 100644 index 0000000..3a17df8 --- /dev/null +++ b/components/analyzer/related-tickets-panel.tsx @@ -0,0 +1,368 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { toast } from 'sonner'; +import { Sparkles, Loader2, Network } from 'lucide-react'; +import type { + AggregateReportStatus, + DiscoveredLinks, + TicketRef, +} from '@/lib/types/analyzer'; + +interface RelatedTicketsPanelProps { + ticketNumber: string; + /** LLM provider for the bundle run. Defaults to 'anthropic'. */ + provider?: 'anthropic' | 'openrouter'; +} + +type Phase = + | 'idle' + | 'starting' + | 'pending_analyses' + | 'pending' + | 'running' + | 'complete' + | 'failed'; + +const REPORT_POLL_TIMEOUT_MS = 10 * 60 * 1000; + +export function RelatedTicketsPanel({ + ticketNumber, + provider = 'anthropic', +}: RelatedTicketsPanelProps) { + const router = useRouter(); + const [links, setLinks] = useState(null); + const [loadError, setLoadError] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [includeSuggested, setIncludeSuggested] = useState(false); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const [phase, setPhase] = useState('idle'); + const [statusLabel, setStatusLabel] = useState(''); + + // Initial cheap fetch. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links` + ); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as DiscoveredLinks; + if (cancelled) return; + setLinks(data); + // Pre-check all explicit refs. + setSelected(new Set(data.explicit.map((r) => r.ticket_number))); + } catch (err) { + if (!cancelled) + setLoadError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, [ticketNumber]); + + async function loadSuggestions(): Promise { + if (!links) return; + setSuggestionsLoading(true); + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/links`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ includeSuggested: true }), + } + ); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as DiscoveredLinks; + setLinks(data); + } catch (err) { + toast.error( + err instanceof Error + ? `Suggestion failed: ${err.message}` + : 'Suggestion failed' + ); + setIncludeSuggested(false); + } finally { + setSuggestionsLoading(false); + } + } + + function toggleRef(ref: TicketRef): void { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(ref.ticket_number)) next.delete(ref.ticket_number); + else next.add(ref.ticket_number); + return next; + }); + } + + async function pollReport(reportId: string): Promise { + const start = Date.now(); + while (Date.now() - start < REPORT_POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, 3000)); + const res = await fetch(`/api/analyzer/aggregate-reports/${reportId}`); + if (!res.ok) throw new Error(`Report poll failed: ${res.status}`); + const data = (await res.json()) as { + report: { status: AggregateReportStatus; errorMessage: string | null }; + }; + const status = data.report.status; + setPhase(status as Phase); + setStatusLabel( + status === 'pending_analyses' + ? 'Analyzing linked tickets…' + : status === 'pending' || status === 'running' + ? 'Building bundle report…' + : status === 'complete' + ? 'Done' + : status === 'failed' + ? 'Failed' + : '' + ); + if (status === 'complete') { + router.push(`/analyzer/reports/${reportId}`); + return; + } + if (status === 'failed') { + throw new Error(data.report.errorMessage ?? 'Bundle report failed'); + } + } + throw new Error('Bundle report timed out after 10 minutes'); + } + + async function submit(opts: { confirmedCost?: boolean } = {}): Promise { + if (selected.size === 0) { + toast.error('Select at least one linked ticket'); + return; + } + setPhase('starting'); + setStatusLabel('Queueing analyses…'); + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze-bundle`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + linkedTicketNumbers: Array.from(selected), + includeItglueContext: true, + confirmedCost: opts.confirmedCost ?? false, + provider, + }), + } + ); + if (res.status === 400) { + const data = (await res.json().catch(() => ({}))) as { + requiresConfirmation?: boolean; + message?: string; + estimatedCost?: number; + }; + if (data.requiresConfirmation) { + const ok = window.confirm( + `${data.message ?? 'Confirmation required'}.\n\nEstimated cost: $${data.estimatedCost?.toFixed(2) ?? '?'}\n\nProceed?` + ); + if (ok) { + await submit({ confirmedCost: true }); + return; + } + setPhase('idle'); + setStatusLabel(''); + return; + } + throw new Error(data.message ?? 'Bundle request rejected'); + } + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as { + aggregateReportId: string; + status: AggregateReportStatus; + }; + setPhase(data.status as Phase); + setStatusLabel( + data.status === 'pending_analyses' + ? 'Analyzing linked tickets…' + : 'Building bundle report…' + ); + await pollReport(data.aggregateReportId); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Bundle failed'); + setPhase('idle'); + setStatusLabel(''); + } + } + + const allRefs = useMemo( + () => (links ? [...links.explicit, ...links.suggested] : []), + [links] + ); + const isRunning = phase !== 'idle' && phase !== 'failed' && phase !== 'complete'; + const selectedCount = selected.size; + const hasContent = links && (links.explicit.length > 0 || links.isProblemTicket); + + if (loadError) { + return ( + + Couldn’t check for related tickets + {loadError} + + ); + } + + if (links === null) { + return ( + + + + + + + + + ); + } + + if (!hasContent) { + // Nothing to show — render nothing, the regular AnalyzeButton on the + // parent page is sufficient. + return null; + } + + return ( + + +
+
+ + + Related tickets detected ({allRefs.length}) + + {links.isProblemTicket && ( + Problem ticket + )} +
+
+ { + const next = Boolean(v); + setIncludeSuggested(next); + if (next && links.suggested.length === 0) { + void loadSuggestions(); + } + }} + /> + +
+
+
+ +

+ {links.isProblemTicket + ? 'This looks like a problem/master ticket. Bundling will analyze every linked ticket and produce a cross-ticket report.' + : 'This ticket references other tickets. Bundle them to get a cross-ticket analysis.'} +

+ +
    + {allRefs.map((ref) => ( +
  • + toggleRef(ref)} + disabled={isRunning} + className="mt-0.5" + /> +
    +
    + {ref.ticket_number} + {ref.confidence === 'high' && ( + + explicit + + )} + {ref.source === 'llm_suggested' && ( + + AI-suggested + + )} + {ref.status_label && ( + + {ref.status_label} + + )} +
    + {ref.title && ( +

    + {ref.title} +

    + )} + {ref.reason && ( +

    + {ref.reason} +

    + )} +
    +
  • + ))} +
+ +
+ + + ({selectedCount + 1} total — master + linked) + +
+
+
+ ); +} diff --git a/components/analyzer/share-modal.tsx b/components/analyzer/share-modal.tsx index d7e0124..fa82e90 100644 --- a/components/analyzer/share-modal.tsx +++ b/components/analyzer/share-modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog, DialogContent, @@ -14,20 +14,104 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { Share2 } from 'lucide-react'; +import { Share2, Clock, Users, Check } from 'lucide-react'; import { toast } from 'sonner'; interface ShareModalProps { analysisId: string; } +interface DirectoryEntry { + email: string; + displayName: string | null; + jobTitle: string | null; + department: string | null; +} + +interface RecentEntry { + email: string; + lastSharedAt: string; +} + +interface RecipientsResponse { + recent: RecentEntry[]; + directory: DirectoryEntry[]; + allowedDomains: string[]; +} + +const MAX_DIRECTORY_VISIBLE = 12; + +function timeAgo(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`; + if (ms < day) return `${Math.round(ms / hour)}h ago`; + return `${Math.round(ms / day)}d ago`; +} + export function ShareModal({ analysisId }: ShareModalProps) { const [open, setOpen] = useState(false); const [recipientEmail, setRecipientEmail] = useState(''); const [note, setNote] = useState(''); const [submitting, setSubmitting] = useState(false); - async function handleSubmit(e: React.FormEvent) { + const [recipients, setRecipients] = useState(null); + const [recipientsError, setRecipientsError] = useState(null); + const [showSuggestions, setShowSuggestions] = useState(false); + + const inputRef = useRef(null); + + // Fetch recipients lazily on first dialog open. + useEffect(() => { + if (!open) return; + if (recipients !== null) return; + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/analyzer/share/recipients'); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as RecipientsResponse; + if (!cancelled) setRecipients(data); + } catch (err) { + if (!cancelled) { + setRecipientsError( + err instanceof Error ? err.message : 'Unknown error' + ); + } + } + })(); + return () => { + cancelled = true; + }; + }, [open, recipients]); + + const filteredDirectory = useMemo(() => { + if (!recipients) return []; + const q = recipientEmail.trim().toLowerCase(); + if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE); + return recipients.directory + .filter( + (d) => + d.email.toLowerCase().includes(q) || + (d.displayName ?? '').toLowerCase().includes(q) + ) + .slice(0, MAX_DIRECTORY_VISIBLE); + }, [recipients, recipientEmail]); + + const hasRecent = (recipients?.recent.length ?? 0) > 0; + + function pick(email: string): void { + setRecipientEmail(email); + setShowSuggestions(false); + inputRef.current?.blur(); + } + + async function handleSubmit(e: React.FormEvent): Promise { e.preventDefault(); setSubmitting(true); try { @@ -66,8 +150,16 @@ export function ShareModal({ analysisId }: ShareModalProps) { } } + // Reset transient state when the dialog closes. + function onOpenChange(next: boolean): void { + setOpen(next); + if (!next) { + setShowSuggestions(false); + } + } + return ( - + + ))} + +
+ Directory +
+ {filteredDirectory.length === 0 ? ( +
+ No matching directory users. +
+ ) : ( + filteredDirectory.map((d) => ( + + )) + )} +
+ )} +
+ {recipientsError && ( +

+ Couldn’t load directory ({recipientsError}). Type any + allowed-domain email to share. +

+ )}
+