diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-SUMMARY.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-SUMMARY.md new file mode 100644 index 0000000..5007729 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-02-SUMMARY.md @@ -0,0 +1,105 @@ +--- +phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve +plan: 02 +subsystem: api +tags: [nextjs, postgres, mimecast, phishing-triage] + +# Dependency graph +requires: + - phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve + provides: "resolveTicketToCampaign() (lib/services/phishing-ticket-resolver.ts) and mergeTimeline() (lib/services/phishing-timeline.ts) from plan 22-01" +provides: + - "GET /api/phishing/tickets/{ticket_id}/campaign — ticket -> campaign resolver route" + - "GET /api/phishing/campaigns/{id} — enriched with remediationActions, auditEvents, widened classifications, widened message evidence, fresh blastRadius, merged timeline" + - "GET /api/phishing/campaigns — enriched with firstReportTicketId per campaign" +affects: [22-03, 22-04, 22-05] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Route additively widens an existing SELECT (add columns, map new camelCase fields) rather than adding a second query round-trip" + - "completedAt derived at read-time from audit_events.payload.actionId rather than a dedicated column" + +key-files: + created: + - app/api/phishing/tickets/[ticket_id]/campaign/route.ts + modified: + - app/api/phishing/campaigns/[id]/route.ts + - app/api/phishing/campaigns/route.ts + +key-decisions: + - "D-07 (resolver route): missing report is { found: false } at HTTP 200, not a 404 — page distinguishes 'valid ticket, not triaged yet' from a hard error" + - "blastRadius sender/recipient/subject derivation copied verbatim from campaign-classifier.ts's gatherCampaignEvidence (not triage-note-service.ts's empty-string call) to avoid an unscoped Mimecast fan-out" + +patterns-established: + - "Ticket-id-addressable resolver route pattern (Number.isFinite param validation, GET not POST for pure lookups)" + +requirements-completed: [REVIEW-01, REVIEW-02, REVIEW-03, REVIEW-04] + +# Metrics +duration: 35min +completed: 2026-07-16 +--- + +# Phase 22 Plan 02: Review Page Read Surface Summary + +**New GET ticket->campaign resolver route plus additive enrichment of both existing campaign endpoints (evidence, timeline, remediation actions, audit trail, fresh blast radius, firstReportTicketId) — the entire backend read surface the Phase 22 review page needs.** + +## Performance + +- **Duration:** 35 min +- **Started:** 2026-07-16T18:09:00Z +- **Completed:** 2026-07-16T18:44:21Z +- **Tasks:** 3 completed +- **Files modified:** 3 (1 created, 2 extended) + +## Accomplishments +- New `GET /api/phishing/tickets/{ticket_id}/campaign` thin resolver route wrapping plan 22-01's `resolveTicketToCampaign()`, auth-gated and param-validated, with the deliberate `found:false`-at-200 design (D-07). +- `GET /api/phishing/campaigns/{id}` additively extended: widened `messages` query returns `headers`/`urls`/`attachments`/`bodyPreview`; widened `classifications` query returns `reasons`/`recommendedActions`/`requiresApproval`; new `remediationActions` (with `completedAt` derived from `audit_events`) and `auditEvents` arrays; a fresh per-request `blastRadius` lookup; and a merged chronological `timeline`. +- `GET /api/phishing/campaigns` additively extended with `firstReportTicketId` per campaign (correlated subquery on the earliest linked report) for row-click navigation on the list page. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: NEW ticket->campaign resolver route** - `ca63910` (feat) +2. **Task 2: EXTEND campaigns/[id] detail route (evidence + timeline + classification + blast radius)** - `9e83ec0` (feat) +3. **Task 3: EXTEND campaigns list route with firstReportTicketId** - `c70b30a` (feat) + +**Plan metadata:** committed alongside this SUMMARY (docs commit, see final commit in this worktree) + +## Files Created/Modified +- `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` - New GET resolver route; auth + param validation + `resolveTicketToCampaign()` call +- `app/api/phishing/campaigns/[id]/route.ts` - Additively extended: widened messages/classifications SELECTs, new remediationActions/auditEvents queries, blastRadius derivation, mergeTimeline() call +- `app/api/phishing/campaigns/route.ts` - Additively extended: `c` alias, correlated subquery for `first_report_ticket_id`, added to `items.map()` + +## Decisions Made +- Followed the plan's D-07 design exactly: the resolver route returns 200 with `found: false` for an untriaged ticket rather than 404, since the ticket itself is valid — only the triage state is "not yet known." +- Copied the blast-radius sender/recipient/subject/dateWindow derivation verbatim from `campaign-classifier.ts`'s `gatherCampaignEvidence` (not `triage-note-service.ts`'s empty-string call) per the plan's explicit Pitfall 3 warning, to avoid an unscoped Mimecast fan-out query. +- Extracted the widened per-row arrays (`reports`, `messages`, `indicators`, `classifications`, `remediationActions`, `auditEvents`) into named `const`s before the final `NextResponse.json(...)` return, so the same camelCased data can feed both the response body and the `blastRadius`/`timeline` derivations without a second query or duplicated mapping logic. + +## Deviations from Plan + +None - plan executed exactly as written. One micro-adjustment made during self-verification: the plan's acceptance criterion for Task 2 requires zero occurrences of the literal string `completed_at` anywhere in the file (to confirm no reference to the nonexistent column), and an initial code comment explaining the `completedAt` derivation happened to spell out `completed_at` in prose. Reworded the comment to describe the same fact without using that literal string — pure documentation wording, no code/behavior change. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- The full backend read surface for the Phase 22 review page (`app/phishing/tickets/[ticketId]/page.tsx`, planned in 22-03/22-04) is now in place: resolver route, enriched campaign detail, enriched campaign list. +- Write routes (approve/remediate/mark-false-positive) already exist from Phase 20 and are reused verbatim — no additional backend work needed before the UI plans (22-03 onward) can wire up against real data. +- Verified via `npx tsc --noEmit --pretty` (fully clean, zero errors in this plan's files or anywhere else) and `npm test` (411 passed, 2 pre-existing/out-of-scope `itglue-search.test.ts` failures — already documented in `deferred-items.md`, unrelated to this plan's files). + +--- +*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve* +*Completed: 2026-07-16* + +## Self-Check: PASSED + +All created/modified files confirmed present on disk; all 4 task/docs commit hashes (ca63910, 9e83ec0, c70b30a, de13cd7) confirmed present in git log. diff --git a/app/api/phishing/campaigns/[id]/route.ts b/app/api/phishing/campaigns/[id]/route.ts index 29808e3..1fb4b9c 100644 --- a/app/api/phishing/campaigns/[id]/route.ts +++ b/app/api/phishing/campaigns/[id]/route.ts @@ -1,12 +1,16 @@ /** * GET /api/phishing/campaigns/[id] * Returns a single campaign with nested linked reports, messages, - * indicators, and classification history. + * indicators, classification history, remediation actions, audit trail, + * a fresh blast-radius lookup, and a merged chronological timeline — + * everything the Phase 22 review page needs in one call. */ import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius'; +import { mergeTimeline } from '@/lib/services/phishing-timeline'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -37,6 +41,10 @@ interface MessageRow { report_id: string; message_id: string | null; subject: string | null; + headers: unknown; + urls: unknown; + attachments: unknown; + body_preview: string | null; } interface IndicatorRow { @@ -52,6 +60,26 @@ interface ClassificationRow { verdict: string; confidence: string | null; summary: string | null; + reasons: unknown; + recommended_actions: unknown; + requires_approval: boolean; + created_at: string; +} + +interface RemediationActionRow { + id: string; + action_type: string; + status: string; + params: unknown; + approved_by: string | null; + approved_at: string | null; +} + +interface AuditEventRow { + id: string; + actor: string | null; + event_type: string; + payload: unknown; created_at: string; } @@ -96,10 +124,13 @@ export async function GET( const reportIds = reportsRes.rows.map((r) => r.id); // Bulk-fetch messages keyed by the report-id array. messages has no - // subject column — subject lives in headers JSONB. + // subject column — subject lives in headers JSONB. Widened to also + // return the full evidence shapes (headers/urls/attachments/body_preview) + // for the review page's evidence card. const messagesRes = reportIds.length ? await postgresClient.query( - `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject + `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject, + headers, urls, attachments, body_preview FROM messages WHERE report_id = ANY($1::uuid[])`, [reportIds] ) @@ -115,14 +146,141 @@ export async function GET( ) : { rows: [] as IndicatorRow[] }; - // Classifications (Phase 19 stub — likely empty this phase, still - // included in the response shape per CAMP-03). + // Classifications — widened to include reasons/recommended_actions/ + // requires_approval for the review page's classification card. const classificationsRes = await postgresClient.query( - `SELECT id::text, verdict, confidence, summary, created_at::text + `SELECT id::text, verdict, confidence, summary, reasons, recommended_actions, + requires_approval, created_at::text FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`, [id] ); + // Remediation actions proposed/approved/executed for this campaign. + const remediationRes = await postgresClient.query( + `SELECT id::text, action_type, status, params, approved_by, approved_at::text + FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`, + [id] + ); + + // Audit trail — approvals, completions, false-positive markings. + const auditRes = await postgresClient.query( + `SELECT id::text, actor, event_type, payload, created_at::text + FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`, + [id] + ); + + const reports = reportsRes.rows.map((r) => ({ + id: r.id, + ticketId: r.ticket_id, + ticketNumber: r.ticket_number, + companyName: r.company_name, + title: r.title, + createdAt: r.created_at, + requesterEmail: r.requester_email, + })); + + const messages = messagesRes.rows.map((m) => ({ + id: m.id, + reportId: m.report_id, + messageId: m.message_id, + subject: m.subject, + headers: m.headers, + urls: m.urls, + attachments: m.attachments, + bodyPreview: m.body_preview, + })); + + const indicators = indicatorsRes.rows.map((i) => ({ + id: i.id, + messageId: i.message_id, + indicatorType: i.indicator_type, + value: i.value, + metadata: i.metadata, + })); + + const classifications = classificationsRes.rows.map((c) => ({ + id: c.id, + verdict: c.verdict, + confidence: c.confidence, + summary: c.summary, + reasons: c.reasons, + recommendedActions: c.recommended_actions, + requiresApproval: c.requires_approval, + createdAt: c.created_at, + })); + + // Derive `completedAt` for each remediation action from the audit trail + // (remediation_actions has no dedicated completion-timestamp column) — + // the 'remediation_completed' audit event's payload.actionId points back + // at the action it completed. + const completedAtByActionId = new Map(); + for (const event of auditRes.rows) { + if (event.event_type === 'remediation_completed') { + const actionId = (event.payload as { actionId?: string } | null)?.actionId; + if (actionId) completedAtByActionId.set(actionId, event.created_at); + } + } + + const remediationActions = remediationRes.rows.map((a) => ({ + id: a.id, + actionType: a.action_type, + status: a.status, + params: a.params, + approvedBy: a.approved_by, + approvedAt: a.approved_at, + completedAt: completedAtByActionId.get(a.id) ?? null, + })); + + const auditEvents = auditRes.rows.map((e) => ({ + id: e.id, + actor: e.actor, + eventType: e.event_type, + payload: e.payload, + createdAt: e.created_at, + })); + + // Fresh blast-radius lookup per request (D-03: never persisted here). + // Sender/recipient/subject/dateWindow derivation copied verbatim from + // lib/services/campaign-classifier.ts's gatherCampaignEvidence — NOT + // triage-note-service.ts's empty-string call (Pitfall 3), which would + // produce an unscoped Mimecast fan-out. + const primaryReport = reports[0] ?? null; + let blastRadius: BlastRadiusResult; + if (primaryReport) { + const primaryMessage = messages.find((m) => m.reportId === primaryReport.id) ?? null; + const senderIndicator = indicators.find( + (i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender' + ); + const messageHeaders = (primaryMessage?.headers ?? null) as + | { from?: { email?: string | null } | null } + | null; + const createdAt = new Date(primaryReport.createdAt); + blastRadius = await getBlastRadius({ + sender: senderIndicator?.value ?? messageHeaders?.from?.email ?? '', + recipient: primaryReport.requesterEmail ?? '', + subject: primaryMessage?.subject ?? primaryReport.title ?? '', + dateWindow: { + start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000), + end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000), + }, + }); + } else { + blastRadius = { status: 'unavailable', reason: 'not_configured' }; + } + + // Merged chronological timeline (reports + classifications + audit + // events), oldest first. + const timeline = mergeTimeline( + reports.map((r) => ({ + createdAt: r.createdAt, + reportId: r.id, + ticketNumber: r.ticketNumber, + companyName: r.companyName, + })), + classifications, + auditEvents + ); + return NextResponse.json({ id: campaign.id, campaignKey: campaign.campaign_key, @@ -133,35 +291,14 @@ export async function GET( status: campaign.status, createdAt: campaign.created_at, updatedAt: campaign.updated_at, - reports: reportsRes.rows.map((r) => ({ - id: r.id, - ticketId: r.ticket_id, - ticketNumber: r.ticket_number, - companyName: r.company_name, - title: r.title, - createdAt: r.created_at, - requesterEmail: r.requester_email, - })), - messages: messagesRes.rows.map((m) => ({ - id: m.id, - reportId: m.report_id, - messageId: m.message_id, - subject: m.subject, - })), - indicators: indicatorsRes.rows.map((i) => ({ - id: i.id, - messageId: i.message_id, - indicatorType: i.indicator_type, - value: i.value, - metadata: i.metadata, - })), - classifications: classificationsRes.rows.map((c) => ({ - id: c.id, - verdict: c.verdict, - confidence: c.confidence, - summary: c.summary, - createdAt: c.created_at, - })), + reports, + messages, + indicators, + classifications, + remediationActions, + auditEvents, + blastRadius, + timeline, }); } catch (err) { console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err); diff --git a/app/api/phishing/campaigns/route.ts b/app/api/phishing/campaigns/route.ts index cbe895b..da9db17 100644 --- a/app/api/phishing/campaigns/route.ts +++ b/app/api/phishing/campaigns/route.ts @@ -20,6 +20,7 @@ interface CampaignRow { report_count: number; status: string; created_at: string; + first_report_ticket_id: string | null; } export async function GET(request: NextRequest) { @@ -37,15 +38,16 @@ export async function GET(request: NextRequest) { let statusFilter = ''; if (status) { params.push(status); - statusFilter = `WHERE status = $${params.length}`; + statusFilter = `WHERE c.status = $${params.length}`; } const campaigns = await postgresClient.query( - `SELECT id::text, campaign_key, group_method, first_seen_at::text, last_seen_at::text, - report_count, status, created_at::text - FROM campaigns + `SELECT c.id::text, c.campaign_key, c.group_method, c.first_seen_at::text, c.last_seen_at::text, + c.report_count, c.status, c.created_at::text, + (SELECT r.ticket_id::text FROM reports r WHERE r.campaign_id = c.id ORDER BY r.created_at ASC LIMIT 1) AS first_report_ticket_id + FROM campaigns c ${statusFilter} - ORDER BY last_seen_at DESC NULLS LAST + ORDER BY c.last_seen_at DESC NULLS LAST LIMIT $1 OFFSET $2`, params ); @@ -72,6 +74,7 @@ export async function GET(request: NextRequest) { reportCount: c.report_count, status: c.status, createdAt: c.created_at, + firstReportTicketId: c.first_report_ticket_id, })); return NextResponse.json({ items, total, limit, offset }); diff --git a/app/api/phishing/tickets/[ticket_id]/campaign/route.ts b/app/api/phishing/tickets/[ticket_id]/campaign/route.ts new file mode 100644 index 0000000..1291f3f --- /dev/null +++ b/app/api/phishing/tickets/[ticket_id]/campaign/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/phishing/tickets/{ticket_id}/campaign + * + * Thin resolver route: given an Autotask ticket id, reports back whether a + * phishing report exists for it and (if grouped) which campaign it belongs + * to. Pure lookup — no side effects, so this is a GET, unlike the sibling + * POST /analyze route. See lib/services/phishing-ticket-resolver.ts for the + * actual query. + * + * D-07: a ticket with no matching report returns `{ found: false }` at HTTP + * 200, NOT a 404 — the review page distinguishes "valid ticket, not triaged + * yet" from a hard error via the `found` boolean. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import { resolveTicketToCampaign } from '@/lib/services/phishing-ticket-resolver'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ ticket_id: string }> } +) { + const { error } = await requirePermission('phishing', 'read'); + if (error) return error; + + const { ticket_id } = await params; + const ticketId = Number(ticket_id); + if (!Number.isFinite(ticketId)) { + return NextResponse.json({ error: 'Invalid ticket_id' }, { status: 400 }); + } + + try { + const resolution = await resolveTicketToCampaign(ticketId); + return NextResponse.json(resolution); + } catch (err) { + console.error('[PHISHING-TICKET-CAMPAIGN] Failed to resolve ticket->campaign', ticketId, err); + return NextResponse.json( + { error: 'Failed to resolve ticket', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}