diff --git a/CLAUDE.md b/CLAUDE.md index c737a51..ca774e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,10 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`, ## Build / run / verify - Dev: `npm run dev` → http://localhost:3100 - Build: `npm run build` (turbopack via Next 16) -- Type check: `npx tsc --noEmit --pretty` — **this is the only automated check**; - there are no unit/integration tests and no CI. +- Type check: `npx tsc --noEmit --pretty` +- Tests: `npm test` (vitest) — currently scoped to `lib/services/analyzer/**` only. + No CI yet; tests are local-only. Other parts of the codebase have no tests — + if you touch them, type-check is the only safety net. - Docker: `docker compose up` from repo root. Postgres applies `migrations/*.sql` on init only (existing volumes won't re-run them). diff --git a/app/analyzer/analysis/[id]/page.tsx b/app/analyzer/analysis/[id]/page.tsx new file mode 100644 index 0000000..18319f9 --- /dev/null +++ b/app/analyzer/analysis/[id]/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect, useState, use } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AnalysisView } from '@/components/analyzer/analysis-view'; +import type { PersistedAnalysis } from '@/lib/types/analyzer'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +export default function AnalysisDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [analysis, setAnalysis] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/analyzer/analyses/${id}`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const { analysis } = (await res.json()) as { analysis: PersistedAnalysis }; + if (!cancelled) setAnalysis(analysis); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, [id]); + + return ( +
+ {error && ( + + Couldn’t load this analysis + {error} + + )} + {!error && !analysis && ( +
+ + + +
+ )} + {analysis && } +
+ ); +} diff --git a/app/analyzer/queue/page.tsx b/app/analyzer/queue/page.tsx new file mode 100644 index 0000000..6178fe2 --- /dev/null +++ b/app/analyzer/queue/page.tsx @@ -0,0 +1,127 @@ +'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 { AlertTriangle } from 'lucide-react'; +import type { PersistedAnalysis } from '@/lib/types/analyzer'; + +export default function AnalyzerQueuePage() { + const [analyses, setAnalyses] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/api/analyzer/needs-review?limit=100`); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] }; + if (!cancelled) setAnalyses(analyses); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

+ + Needs human review +

+

+ AI analyses that flagged themselves for human review — high-severity + gaps, low confidence, conflicting evidence, or cost-ceiling skips. +

+
+ + {error && ( + + Couldn’t load review queue + {error} + + )} + + + + + Queue + {analyses && ( + + {analyses.length} + + )} + + + + {analyses === null && !error ? ( +
+ + + +
+ ) : analyses && analyses.length === 0 ? ( +

+ Nothing flagged. 🎉 +

+ ) : ( +
    + {(analyses ?? []).map((a) => ( +
  • +
    +
    + + {a.ticketNumber} · v{a.analysisVersion} + + {a.summary && ( +

    + {a.summary} +

    + )} + {(a.humanReviewReasons ?? []).length > 0 && ( +
      + {(a.humanReviewReasons ?? []).slice(0, 3).map((r, i) => ( +
    • {r}
    • + ))} +
    + )} +

    + {new Date(a.triggeredAt).toLocaleString()} +

    +
    +
    + {a.confidenceScore !== null && ( + + {Math.round(a.confidenceScore * 100)}% + + )} + {(a.gaps ?? []).some((g) => g.severity === 'high') && ( + + high gap + + )} +
    +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/analyzer/ticket/[ticketNumber]/page.tsx b/app/analyzer/ticket/[ticketNumber]/page.tsx new file mode 100644 index 0000000..f3cfec9 --- /dev/null +++ b/app/analyzer/ticket/[ticketNumber]/page.tsx @@ -0,0 +1,130 @@ +'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 { 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 type { PersistedAnalysis } from '@/lib/types/analyzer'; + +export default function TicketAnalyzerPage({ + params, +}: { + params: Promise<{ ticketNumber: string }>; +}) { + const { ticketNumber } = use(params); + const [analyses, setAnalyses] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyses` + ); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Request failed: ${res.status}`); + } + const { analyses } = (await res.json()) as { analyses: PersistedAnalysis[] }; + if (!cancelled) setAnalyses(analyses); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error'); + } + })(); + return () => { + cancelled = true; + }; + }, [ticketNumber]); + + const latest = analyses?.[0]; + + return ( +
+ + +
+
+

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. +

+
+
+ + {error && ( + + Couldn’t load analysis history + {error} + + )} + + + + Analysis history + + + {analyses === null && !error ? ( +
+ + +
+ ) : analyses && analyses.length === 0 ? ( +

+ No analyses yet. Run one above. +

+ ) : ( +
    + {(analyses ?? []).map((a) => ( +
  • +
    + + + Version {a.analysisVersion} + {latest?.id === a.id && ( + + latest + + )} + {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)}% + + )} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/api/analyzer/analyses/[id]/route.ts b/app/api/analyzer/analyses/[id]/route.ts new file mode 100644 index 0000000..169e94d --- /dev/null +++ b/app/api/analyzer/analyses/[id]/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/analyzer/analyses/:id + * + * Fetch a specific analysis by id, including the full timeline / gaps / + * IT Glue references. Returns 404 if not found. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { getAnalysisById } from '@/lib/services/analyzer/persistence'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { id } = await params; + const analysis = await getAnalysisById(id); + if (!analysis) { + return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }); + } + return NextResponse.json({ analysis }); +} diff --git a/app/api/analyzer/analyses/[id]/share/route.ts b/app/api/analyzer/analyses/[id]/share/route.ts new file mode 100644 index 0000000..bca7cbe --- /dev/null +++ b/app/api/analyzer/analyses/[id]/share/route.ts @@ -0,0 +1,91 @@ +/** + * POST /api/analyzer/analyses/:id/share + * + * Body: { recipientEmail: string, note?: string } + * + * Records a share row in analyzer_shares. Email send is deferred to phase 8 + * — this endpoint validates the recipient domain against ALLOWED_SHARE_DOMAINS + * and persists the audit row. The share is "pending delivery" until phase 8 + * wires up nodemailer. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { ShareAnalysisRequest } from '@/lib/types/analyzer'; +import { + createShare, + getAnalysisById, +} from '@/lib/services/analyzer/persistence'; + +function getAllowedDomains(): string[] { + const raw = process.env.ALLOWED_SHARE_DOMAINS ?? ''; + return raw + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); +} + +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 result = ShareAnalysisRequest.safeParse(body); + if (!result.success) { + return NextResponse.json( + { error: 'Invalid request body', details: result.error.issues }, + { status: 400 } + ); + } + const { recipientEmail, note } = result.data; + + const allowedDomains = getAllowedDomains(); + if (allowedDomains.length === 0) { + return NextResponse.json( + { + error: 'Sharing is not configured', + message: 'ALLOWED_SHARE_DOMAINS env var is empty.', + }, + { status: 503 } + ); + } + const domain = recipientEmail.split('@')[1]?.toLowerCase(); + if (!domain || !allowedDomains.includes(domain)) { + return NextResponse.json( + { + error: 'Recipient domain is not allowed', + message: `Allowed domains: ${allowedDomains.join(', ')}`, + }, + { status: 403 } + ); + } + + // Confirm the analysis exists. + const analysis = await getAnalysisById(id); + if (!analysis) { + return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }); + } + + const userId = (session?.user as { id: string }).id; + const share = await createShare({ + analysis_id: id, + shared_by_user_id: userId, + shared_with_email: recipientEmail, + note, + }); + + return NextResponse.json({ + share: { + id: share.id, + analysisId: id, + sharedWithEmail: recipientEmail, + sharedAt: share.shared_at, + note: note ?? null, + }, + }); +} diff --git a/app/api/analyzer/jobs/[jobId]/route.ts b/app/api/analyzer/jobs/[jobId]/route.ts new file mode 100644 index 0000000..1a1e02c --- /dev/null +++ b/app/api/analyzer/jobs/[jobId]/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/analyzer/jobs/:jobId + * + * Returns the current status of an analyzer job. Frontend polls this every + * ~2 seconds during a run. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { getJob } from '@/lib/services/analyzer/persistence'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ jobId: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { jobId } = await params; + const job = await getJob(jobId); + if (!job) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + return NextResponse.json({ job }); +} diff --git a/app/api/analyzer/needs-review/route.ts b/app/api/analyzer/needs-review/route.ts new file mode 100644 index 0000000..7e69eb8 --- /dev/null +++ b/app/api/analyzer/needs-review/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/analyzer/needs-review + * + * Returns the queue of completed analyses where needs_human_review = true. + * Optional query params: limit (default 50, max 200), offset (default 0). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listNeedsReview } from '@/lib/services/analyzer/persistence'; + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const params = request.nextUrl.searchParams; + const limit = parseInt(params.get('limit') ?? '50', 10); + const offset = parseInt(params.get('offset') ?? '0', 10); + + const analyses = await listNeedsReview({ + limit: Number.isFinite(limit) ? limit : 50, + offset: Number.isFinite(offset) ? offset : 0, + }); + return NextResponse.json({ analyses }); +} diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyses/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyses/route.ts new file mode 100644 index 0000000..e3c381c --- /dev/null +++ b/app/api/analyzer/tickets/[ticketNumber]/analyses/route.ts @@ -0,0 +1,21 @@ +/** + * GET /api/analyzer/tickets/:ticketNumber/analyses + * + * Lists all analysis versions for a ticket, newest first. + */ + +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { listAnalysesByTicketNumber } from '@/lib/services/analyzer/persistence'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ ticketNumber: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + const { ticketNumber } = await params; + const analyses = await listAnalysesByTicketNumber(ticketNumber); + return NextResponse.json({ analyses }); +} diff --git a/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts new file mode 100644 index 0000000..c58bcbe --- /dev/null +++ b/app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts @@ -0,0 +1,101 @@ +/** + * POST /api/analyzer/tickets/:ticketNumber/analyze + * + * Body: { force?: boolean } + * Response on existing match (force=false): + * { status: "complete", existingAnalysisId, existingAnalysisVersion } + * Response when queued: + * { status: "queued", jobId } + * + * The worker (lib/services/analyzer/worker.ts) picks up queued jobs and runs + * the full pipeline. Frontend should poll GET /api/analyzer/jobs/:jobId. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { AnalyzeTicketRequest } from '@/lib/types/analyzer'; +import { + loadTicketBundle, + TicketNotFoundError, +} from '@/lib/services/analyzer/data-access'; +import { preprocessTicket } from '@/lib/services/analyzer/preprocessor'; +import { + findExistingAnalysisByContentHash, + queueJob, +} from '@/lib/services/analyzer/persistence'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ ticketNumber: string }> } +) { + const { session, error } = await requireAuth(); + if (error) return error; + + const { ticketNumber } = await params; + + let parsedBody: { force?: boolean }; + try { + const body = await request.json().catch(() => ({})); + const result = AnalyzeTicketRequest.safeParse(body); + if (!result.success) { + return NextResponse.json( + { error: 'Invalid request body', details: result.error.issues }, + { status: 400 } + ); + } + parsedBody = result.data; + } catch { + parsedBody = {}; + } + + // Verify the ticket exists and load its data for the idempotency check. + 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('[analyze] data-access error:', err); + return NextResponse.json( + { + error: 'Failed to load ticket', + message: err instanceof Error ? err.message : 'unknown', + }, + { status: 500 } + ); + } + + // Idempotency short-circuit: when force=false, return the existing analysis + // without queueing a job if the source data hasn't changed since. + if (!parsedBody.force) { + const pre = preprocessTicket(bundle); + const existing = await findExistingAnalysisByContentHash( + ticketNumber, + pre.content_hash + ); + if (existing) { + return NextResponse.json({ + status: 'complete', + existingAnalysisId: existing.id, + existingAnalysisVersion: existing.analysis_version, + }); + } + } + + // Queue a new job. The worker self-init at server-start (production) will + // pick it up; in dev set ANALYZER_WORKER_AUTOSTART=1. + const userId = (session?.user as { id: string }).id; + const job = await queueJob({ + ticket_number: ticketNumber, + queued_by_user_id: userId, + }); + + return NextResponse.json({ + status: 'queued', + jobId: job.id, + }); +} diff --git a/components/analyzer/analysis-view.tsx b/components/analyzer/analysis-view.tsx new file mode 100644 index 0000000..b4ad5f5 --- /dev/null +++ b/components/analyzer/analysis-view.tsx @@ -0,0 +1,399 @@ +'use client'; + +import { 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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Separator } from '@/components/ui/separator'; +import { ChevronRight, ChevronDown, ExternalLink, AlertTriangle } from 'lucide-react'; +import { ShareModal } from './share-modal'; +import { AnalyzeButton } from './analyze-button'; +import type { PersistedAnalysis, Visibility } from '@/lib/types/analyzer'; + +interface AnalysisViewProps { + analysis: PersistedAnalysis; +} + +const VISIBILITY_MARKER: Record = { + customer_facing: '🟢', + internal_only: '🔒', + mixed: '🔄', +}; + +const VISIBILITY_LABEL: Record = { + customer_facing: 'Customer-facing', + internal_only: 'Internal only', + mixed: 'Mixed (both)', +}; + +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', +}; + +function ConfidenceBadge({ score }: { score: number | null }) { + if (score === null) return null; + const pct = Math.round(score * 100); + const tone = + score >= 0.8 ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400' + : score >= 0.6 ? 'bg-amber-500/10 text-amber-700 dark:text-amber-400' + : 'bg-red-500/10 text-red-700 dark:text-red-400'; + return ( + + Confidence {pct}% + + ); +} + +function ModelBadges({ a }: { a: PersistedAnalysis }) { + return ( +
+ {a.haikuUsed && Haiku} + {a.sonnetUsed && Sonnet} + {a.opusUsed && Opus} +
+ ); +} + +export function AnalysisView({ analysis: a }: AnalysisViewProps) { + const [expandedEvent, setExpandedEvent] = useState(null); + const [nextStepOpen, setNextStepOpen] = useState(false); + + const timelineByTimestamp = useMemo(() => { + const map = new Map(); + (a.timeline ?? []).forEach((event, idx) => { + if (!map.has(event.timestamp)) map.set(event.timestamp, idx); + }); + return map; + }, [a.timeline]); + + function jumpToEvent(timestamp: string) { + const idx = timelineByTimestamp.get(timestamp); + if (idx === undefined) return; + setExpandedEvent(idx); + document + .getElementById(`timeline-event-${idx}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + return ( +
+ {/* 1. Header */} + + +
+
+
+ + {a.ticketNumber} + + + v{a.analysisVersion} + + {a.needsHumanReview && ( + Needs review + )} +
+ + AI Analysis · {new Date(a.triggeredAt).toLocaleString()} + +

+ {a.totalInputTokens.toLocaleString()} in /{' '} + {a.totalOutputTokens.toLocaleString()} out tokens · cost{' '} + {`$${a.estimatedCostUsd.toFixed(4)}`} +

+
+
+
+ + +
+
+ + +
+
+
+
+
+ + {/* 2. Summary */} + {a.summary && ( + + + Summary + + +

+ {a.summary} +

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

{a.nextStep}

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

+ {a.nextStepRationale} +

+
+
+ )} +
+
+ )} + + {/* 4. Timeline */} + {a.timeline && a.timeline.length > 0 && ( + + + Timeline + + +
    + {a.timeline.map((event, idx) => ( +
  1. + + +
  2. + ))} +
+
+
+ )} + + {/* 5+6. What was done / should have been done — side by side on wide */} + {((a.whatWasDone?.length ?? 0) > 0 || + (a.whatShouldHaveBeenDone?.length ?? 0) > 0) && ( +
+ + + What was done + + +
    + {(a.whatWasDone ?? []).map((item, i) => ( +
  • {item}
  • + ))} +
+
+
+ + + + What should have been done + + + +
    + {(a.whatShouldHaveBeenDone ?? []).map((item, i) => ( +
  • {item}
  • + ))} +
+
+
+
+ )} + + {/* 7. Gaps */} + {a.gaps && a.gaps.length > 0 && ( + + + Gaps + + + {a.gaps.map((gap, i) => ( +
+
+ + {gap.severity} + +

{gap.description}

+
+ {gap.evidence_timestamps.length > 0 && ( +
+ Evidence:{' '} + {gap.evidence_timestamps.map((ts, j) => ( + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* 8. Post-resolution analysis */} + {a.postResolutionAnalysis && ( + + + Post-resolution analysis + + +

+ {a.postResolutionAnalysis} +

+
+
+ )} + + {/* 9. Human review flags */} + {a.needsHumanReview && ( + + + + + Human review flags + + + +
    + {(a.humanReviewReasons ?? []).map((reason, i) => ( +
  • {reason}
  • + ))} +
+
+
+ )} + + {/* 10. IT Glue references */} + {a.itglueDocsReferenced.length > 0 && ( + + + IT Glue references + + +
    + {a.itglueDocsReferenced.map((doc) => ( +
  • + {doc.doc_type} +
    + {doc.url ? ( + + {doc.name} + + + ) : ( + {doc.name} + )} +

    + {doc.relevance_reason} +

    +
    +
  • + ))} +
+
+
+ )} + + {/* Expanded event detail (rendered separately so it floats independently) */} + {expandedEvent !== null && a.timeline?.[expandedEvent] && ( + + + Event detail + + +

+ {a.timeline[expandedEvent].actor} ·{' '} + {new Date(a.timeline[expandedEvent].timestamp).toLocaleString()} +

+

{a.timeline[expandedEvent].action}

+ +

+ Visibility: {VISIBILITY_LABEL[a.timeline[expandedEvent].visibility]} + {' · '} Source: {a.timeline[expandedEvent].source} + {' · '} Actor type: {a.timeline[expandedEvent].actor_type} +

+
+
+ )} +
+ ); +} diff --git a/components/analyzer/analyze-button.tsx b/components/analyzer/analyze-button.tsx new file mode 100644 index 0000000..a462cb3 --- /dev/null +++ b/components/analyzer/analyze-button.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { Sparkles, Loader2 } from 'lucide-react'; +import type { JobStatus } from '@/lib/types/analyzer'; + +interface AnalyzeButtonProps { + ticketNumber: string; + /** Force a re-run even if the content hash matches an existing analysis. */ + force?: boolean; + variant?: 'default' | 'outline' | 'secondary'; + label?: string; +} + +const STAGE_LABEL: Record = { + queued: 'Queued…', + fetching: 'Fetching…', + triaging: 'Triaging…', + itglue: 'Searching IT Glue…', + analyzing: 'Analyzing…', + deep_review: 'Deep review…', + complete: 'Done', + failed: 'Failed', +}; + +export function AnalyzeButton({ + ticketNumber, + force = false, + variant = 'default', + label = 'Analyze', +}: AnalyzeButtonProps) { + const router = useRouter(); + const [status, setStatus] = useState('idle'); + + async function pollJob(jobId: string) { + const start = Date.now(); + const TIMEOUT_MS = 5 * 60 * 1000; + while (Date.now() - start < TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, 2000)); + const res = await fetch(`/api/analyzer/jobs/${jobId}`); + if (!res.ok) throw new Error(`Job poll failed: ${res.status}`); + const { job } = (await res.json()) as { + job: { status: JobStatus; resultAnalysisId: string | null; errorMessage: string | null }; + }; + setStatus(job.status); + if (job.status === 'complete' && job.resultAnalysisId) { + return job.resultAnalysisId; + } + if (job.status === 'failed') { + throw new Error(job.errorMessage ?? 'Analysis failed'); + } + } + throw new Error('Analysis timed out after 5 minutes'); + } + + async function handleClick() { + setStatus('queued'); + try { + const res = await fetch( + `/api/analyzer/tickets/${encodeURIComponent(ticketNumber)}/analyze`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force }), + } + ); + if (!res.ok) { + const error = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(error.error ?? `Request failed: ${res.status}`); + } + const data = (await res.json()) as + | { status: 'complete'; existingAnalysisId: string } + | { status: 'queued'; jobId: string }; + + if (data.status === 'complete') { + router.push(`/analyzer/analysis/${data.existingAnalysisId}`); + return; + } + + const analysisId = await pollJob(data.jobId); + router.push(`/analyzer/analysis/${analysisId}`); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + toast.error(msg); + setStatus('idle'); + } + } + + const isRunning = status !== 'idle' && status !== 'failed'; + + return ( + + ); +} diff --git a/components/analyzer/share-modal.tsx b/components/analyzer/share-modal.tsx new file mode 100644 index 0000000..031f399 --- /dev/null +++ b/components/analyzer/share-modal.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +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 { toast } from 'sonner'; + +interface ShareModalProps { + analysisId: string; +} + +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) { + e.preventDefault(); + setSubmitting(true); + try { + const res = await fetch(`/api/analyzer/analyses/${analysisId}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientEmail, + note: note.trim() || undefined, + }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; + throw new Error(data.message ?? data.error ?? `Request failed: ${res.status}`); + } + toast.success(`Shared with ${recipientEmail}`); + setOpen(false); + setRecipientEmail(''); + setNote(''); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + toast.error(msg); + } finally { + setSubmitting(false); + } + } + + return ( + + + + + + + Share this analysis + + Recipient must be on an allowed domain (set via + ALLOWED_SHARE_DOMAINS). + + +
+
+ + setRecipientEmail(e.target.value)} + placeholder="colleague@wulfconsulting.com" + /> +
+
+ +