Merge branch 'worktree-agent-a68fa4bcada53d003'

This commit is contained in:
lorentz 2026-07-16 14:45:47 -04:00
commit bced734717
4 changed files with 327 additions and 40 deletions

View file

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

View file

@ -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<MessageRow>(
`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<ClassificationRow>(
`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<RemediationActionRow>(
`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<AuditEventRow>(
`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<string, string>();
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);

View file

@ -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<CampaignRow>(
`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 });

View file

@ -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 }
);
}
}