docs(22): create phase plan

This commit is contained in:
lorentz 2026-07-16 14:10:33 -04:00
parent 476eb017eb
commit 4b5e31c068
5 changed files with 558 additions and 16 deletions

View file

@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v3.0
milestone_name: Phishing Triage Automation
status: planning
stopped_at: Phase 22 context gathered
last_updated: "2026-07-16T16:49:08.319Z"
last_activity: 2026-07-16
status: executing
stopped_at: Phase 22 UI-SPEC approved
last_updated: "2026-07-16T18:09:53.334Z"
last_activity: 2026-07-16 -- Phase 22 planning complete
progress:
total_phases: 8
completed_phases: 7
total_plans: 18
total_plans: 24
completed_plans: 18
percent: 88
percent: 75
---
# Project State
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-14)
Phase: 22
Plan: Not started
Status: Ready to plan
Last activity: 2026-07-16
Status: Ready to execute
Last activity: 2026-07-16 -- Phase 22 planning complete
Progress: [░░░░░░░░░░] 0%
@ -126,7 +126,7 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-07-16T16:49:08.315Z
Stopped at: Phase 22 context gathered
Resume file: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-CONTEXT.md
Last session: 2026-07-16T17:19:02.949Z
Stopped at: Phase 22 UI-SPEC approved
Resume file: .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md
</content>

View file

@ -12,10 +12,11 @@ autonomous: true
requirements: [REVIEW-03]
must_haves:
truths:
- "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link"
- "Extracted URLs render as inert monospace text with a copy-to-clipboard button — never as a clickable link (D-09)"
- "The evidence card shows parsed Headers, URLs, Attachments, Body preview, and Blast Radius tabs for a selected message"
- "Body preview renders as plain text in a <pre>, never via dangerouslySetInnerHTML"
- "Blast radius renders an explicit 'unavailable' state when status is unavailable, and counts + per-recipient table when ok"
- "Attachment metadata (filename, content-type, size, hash) is displayed as plain evidence, as-is, with no extra redaction (D-10)"
artifacts:
- path: "components/ui/tooltip.tsx"
provides: "shadcn tooltip primitive (for later disabled-button explanations)"

View file

@ -10,9 +10,9 @@ autonomous: true
requirements: [REVIEW-05, REVIEW-06]
must_haves:
truths:
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call (D-03)"
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation) (D-04)"
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces (D-06), or when the campaign is already resolved (D-05)"
- "When classification is null (grouped-but-unclassified default state), ActionAreaCard renders an informational note and no action rows/buttons instead of dereferencing recommendedActions — it never crashes on the initial unclassified state"
artifacts:
- path: "components/phishing/action-area-card.tsx"

View file

@ -13,9 +13,10 @@ requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
must_haves:
truths:
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
- "The review page uses Pulse's full navigation shell — AppNavigation, PageHeader, container mx-auto px-6 py-6 — same chrome as /admin, /analyzer (D-01)"
- "The review page renders classification, action area, evidence, and timeline for a CLASSIFIED grouped campaign; a 'Not yet classified' empty state with a 'Classify this campaign' CTA for a grouped-but-unclassified campaign (the default post-grouping state); a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
- "A grouped campaign with zero classifications (the default state right after detection/grouping, since classification is never auto-triggered) never crashes — ClassificationCard and ActionAreaCard are not rendered, a Classify CTA (POST /classify) is offered instead, and evidence + timeline still display"
- "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state"
- "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state (D-04)"
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
- "A 'Phishing' nav entry links to /phishing"
- "Phase completion is gated on a manual confirmation that a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the documented ticket_number fallback is applied)"

View file

@ -0,0 +1,540 @@
# Phase 22: Approval UI (LiveLink) - Pattern Map
**Mapped:** 2026-07-16
**Files analyzed:** 14 (new/modified)
**Analogs found:** 14 / 14
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` | route (resolver) | request-response | `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` | exact (same param idiom, sibling route family) |
| `app/api/phishing/campaigns/[id]/route.ts` (EXTEND) | route (controller) | CRUD (read, bulk-fetch) | itself (existing file, additive change) | exact |
| `app/api/phishing/campaigns/route.ts` (EXTEND) | route (controller) | CRUD (read, list+paginate) | itself (existing file, additive `firstReportTicketId` field) | exact |
| `lib/services/phishing-ticket-resolver.ts` (NEW, recommended extraction) | service | CRUD (single lookup) | `lib/services/campaign-classifier.ts`'s `gatherCampaignEvidence` (service extraction pattern) + `analyze/route.ts`'s inline query | role-match |
| `lib/services/remediation-default-params.ts` (NEW, recommended extraction) | utility | transform (pure derivation) | `lib/services/remediation-service.ts`'s `parseRecommendedActions` (small pure-function-in-service-file idiom) | role-match |
| `app/phishing/page.tsx` | component (page) | CRUD (list + navigate) | `app/admin/data-browser/companies/page.tsx` | exact |
| `app/phishing/tickets/[ticketId]/page.tsx` | component (page) | request-response (fetch + action POSTs) | `app/analyzer/reports/[id]/page.tsx` | exact |
| `components/phishing/classification-card.tsx` | component | request-response (read-only render) | `app/analyzer/reports/[id]/page.tsx`'s Card sections (inline, not extracted) | role-match |
| `components/phishing/action-area-card.tsx` | component | event-driven (checkbox state + POST actions) | `components/rmm/rmm-dispatch-dialog.tsx` | exact (client-side permission gate + fetch-POST-toast pattern) |
| `components/phishing/evidence-card.tsx` | component | request-response (tabbed read-only render) | `components/admin/DetailModal.tsx` (card+tabs precedent) | role-match |
| `components/phishing/timeline-card.tsx` | component | transform (client-side merge/sort + render) | none direct — synthesized from `components/ui/status-light.tsx` (dot size token) + plain layout | no analog (new pattern) |
| `components/phishing/url-list.tsx` | component | transform (inert render + clipboard) | none direct — synthesized from UI-SPEC's explicit spec | no analog (new pattern) |
| `components/ui/tooltip.tsx` | component (shadcn primitive) | n/a | shadcn official registry (`npx shadcn add tooltip`) | n/a — generated, not hand-written |
| `components/navigation/app-navigation.tsx` (MODIFY) | config/navigation | n/a (static array edit) | itself (existing `navigationItems` array, PAX8 entry as the shape to copy) | exact |
## Pattern Assignments
### `app/api/phishing/tickets/[ticket_id]/campaign/route.ts` (route, request-response)
**Analog:** `app/api/phishing/tickets/[ticket_id]/analyze/route.ts` (read in full, 91 lines)
**Imports pattern** (lines 1-16 of analog):
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
```
**Auth + param-validation pattern** (lines 18-29 of analog):
```typescript
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ ticket_id: string }> }
) {
const { error } = await requirePermission('phishing', 'read'); // 'read' not 'analyze' for a pure lookup
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 });
}
```
Use `GET` not `POST` for this new route (pure read, no side effect) — same signature shape otherwise.
**Core lookup pattern (new, D-07/D-08 aware)** — mirrors the analog's `postgresClient.query` + row-shape idiom but simpler (single table, no joins needed beyond `reports`):
```typescript
const res = await postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>(
`SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
[ticketId]
);
const report = res.rows[0];
if (!report) {
// D-07: 200 + found:false, NOT a 404 — see analog's own 400 (bad input) vs
// this route's deliberate 200 (valid input, no data yet) distinction.
return NextResponse.json({ found: false }, { status: 200 });
}
return NextResponse.json({
found: true,
reportId: report.id,
campaignId: report.campaign_id, // null => D-08 ungrouped-report path
ticketNumber: report.ticket_number,
});
```
**Error handling pattern** (lines 83-89 of analog, copy verbatim structure):
```typescript
} 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 }
);
}
```
---
### `app/api/phishing/campaigns/[id]/route.ts` (EXTEND) (route, CRUD)
**Analog:** itself — `app/api/phishing/campaigns/[id]/route.ts` (read in full, 174 lines). This is an in-place additive extension, not a new file.
**Existing bulk-fetch idiom to replicate for each new field** (lines 84-124 — the `reportsRes`/`messagesRes`/`indicatorsRes`/`classificationsRes` pattern: query by `campaign_id = $1` or `ANY($1::uuid[])`, then `.map()` into camelCase):
```typescript
const reportsRes = await postgresClient.query<ReportRow>(
`SELECT r.id::text, r.ticket_id::text, r.ticket_number, r.company_name,
r.title, r.created_at::text, c.email_address AS requester_email
FROM reports r
LEFT JOIN contacts c ON c.id = r.requester_contact_id
WHERE r.campaign_id = $1
ORDER BY r.created_at ASC`,
[id]
);
```
**New field 1 — `remediationActions`** (add alongside the existing `classificationsRes` query, same file/section):
```typescript
const remediationRes = await postgresClient.query<{
id: string; action_type: string; status: string; params: unknown;
approved_by: string | null; approved_at: string | null;
}>(
`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]
);
```
**New field 2 — `auditEvents`**:
```typescript
const auditRes = await postgresClient.query<{
id: string; actor: string | null; event_type: string; payload: unknown; created_at: string;
}>(
`SELECT id::text, actor, event_type, payload, created_at::text
FROM audit_events WHERE campaign_id = $1 ORDER BY created_at ASC`,
[id]
);
```
**New field 3 — extend the existing `classifications` query** (widen the existing `SELECT` at lines 120-124, adding columns already on the table — no schema change):
```typescript
`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`
```
**New field 4 — `blastRadius`, fresh every request.** Copy the sender/recipient derivation from `lib/services/campaign-classifier.ts`'s `gatherCampaignEvidence` (lines 331-345, read in full) — NOT `triage-note-service.ts`'s empty-string call:
```typescript
// Source: lib/services/campaign-classifier.ts lines 331-345 (gatherCampaignEvidence)
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'
);
blastRadius = await getBlastRadius({
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
recipient: primaryReport.requesterEmail ?? '',
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
dateWindow: { start: /* derive from primaryReport.createdAt */ new Date(...), end: new Date() },
});
} else {
blastRadius = { status: 'unavailable', reason: 'not_configured' };
}
```
Import: `import { getBlastRadius, type BlastRadiusResult } from '@/lib/services/mimecast-blast-radius';`
**Derived `completedAt` for remediation_actions (Pitfall 2/Pattern 3, no schema change)**:
```typescript
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 })?.actionId;
if (actionId) completedAtByActionId.set(actionId, event.created_at);
}
}
// remediationActions: remediationRes.rows.map(a => ({ ...camelCased, completedAt: completedAtByActionId.get(a.id) ?? null }))
```
**Error handling** (lines 166-172 of the existing file, unchanged — do not alter):
```typescript
} catch (err) {
console.error('[PHISHING-CAMPAIGN-DETAIL] Failed to load campaign', id, err);
return NextResponse.json(
{ error: 'Failed to load campaign', message: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 }
);
}
```
---
### `app/api/phishing/campaigns/route.ts` (EXTEND) (route, CRUD)
**Analog:** itself — read in full (86 lines). Add one `LEFT JOIN LATERAL` (or subquery) for `firstReportTicketId` inside the existing `campaigns.rows.map()` at lines 66-75:
```typescript
// Existing SELECT (lines 43-51) gets one additive column:
`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 c.last_seen_at DESC NULLS LAST
LIMIT $1 OFFSET $2`
```
Then add `firstReportTicketId: c.first_report_ticket_id` to the existing `items.map()` (line 66-75) — purely additive, no existing field touched.
---
### `lib/services/phishing-ticket-resolver.ts` (NEW — recommended extraction for testability)
**Analog:** No direct extracted-service analog exists for this exact shape; follow the general "small pure service function wrapping a `postgresClient.query`" idiom used throughout `lib/services/campaign-classifier.ts` and `lib/services/remediation-service.ts` (both: plain exported `async function`, typed row interface, `postgresClient` import, no class).
**Pattern to copy** (shape, from `remediation-service.ts`'s top-level exported functions):
```typescript
import { postgresClient } from './postgres-client';
export interface TicketCampaignResolution {
found: boolean;
reportId?: string;
campaignId?: string | null;
ticketNumber?: string | null;
}
export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution> {
const res = await postgresClient.query<{ id: string; campaign_id: string | null; ticket_number: string | null }>(
`SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
[ticketId]
);
const report = res.rows[0];
if (!report) return { found: false };
return { found: true, reportId: report.id, campaignId: report.campaign_id, ticketNumber: report.ticket_number };
}
```
The route then becomes a thin `requirePermission` + param-parse + call wrapper (Wave 0 test recommendation from RESEARCH.md: `npx vitest run lib/services/phishing-ticket-resolver.test.ts`, matches `vitest.config.ts`'s `lib/**/*.test.ts` include pattern).
---
### `lib/services/remediation-default-params.ts` (NEW — recommended extraction for testability)
**Analog:** `lib/services/remediation-service.ts`'s `parseRecommendedActions` (lines 63-75) — small, pure, exported-adjacent helper function pattern; also `campaign-classifier.ts`'s `mapVerdictToActions` (exhaustive-switch-like mapping) as the shape for the 7-action lookup table.
**Pattern to copy** (pure function signature + switch, from `mapVerdictToActions`):
```typescript
// Source shape: lib/services/campaign-classifier.ts mapVerdictToActions()
export function deriveDefaultParams(
actionType: string,
evidence: { requesterEmail: string | null; senderEmail: string | null; senderDomain: string | null; messageId: string | null }
): Record<string, unknown> {
switch (actionType) {
case 'no_action': return {};
case 'warn_user': return { recipientEmail: evidence.requesterEmail ?? '', message: '' };
case 'block_sender': return { senderEmail: evidence.senderEmail ?? '', senderDomain: evidence.senderDomain ?? '' };
case 'purge_message': return { messageId: evidence.messageId ?? '', mailboxes: [] };
case 'reset_password': return { userPrincipalName: evidence.requesterEmail ?? '' };
case 'isolate_endpoint': return { deviceId: '' };
case 'disable_forwarding_rule': return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
default: return {};
}
}
```
Exact 7-row table is fully specified in UI-SPEC's Action Area Spec — this is a pure transform, unit-testable per RESEARCH.md's Wave 0 gap (`npx vitest run lib/services/remediation-default-params.test.ts`).
---
### `app/phishing/page.tsx` (component/page, CRUD list)
**Analog:** `app/admin/data-browser/companies/page.tsx` (read lines 1-90) — closest existing `'use client'` + `DataTable` + pagination-state + row-click-navigate pattern in the codebase, even though it's an admin data-browser page rather than a phishing-specific one.
**Imports pattern** (lines 1-10 of analog):
```typescript
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
```
Add: `AppNavigation`/`PageHeader` are supplied by the root layout + `components/navigation/page-header.tsx` — import `PageHeader` from `@/components/navigation/page-header` directly, per UI-SPEC D-01.
**State + fetch pattern** (lines 38-73 of analog — adapt `fetchCompanies` to call `/api/phishing/campaigns`):
```typescript
const [campaigns, setCampaigns] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const fetchCampaigns = async (currentPage: number) => {
setIsLoading(true);
try {
const params = new URLSearchParams({ limit: pageSize.toString(), offset: String((currentPage - 1) * pageSize) });
const response = await fetch(`/api/phishing/campaigns?${params}`);
const result = await response.json();
setCampaigns(result.items || []);
setTotalCount(result.total || 0);
} catch (error) {
console.error('Failed to fetch campaigns:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchCampaigns(page); }, [page]);
```
**Row-click navigation** (lines 75-78 of analog, adapted per UI-SPEC — navigate, don't open a modal):
```typescript
const router = useRouter(); // next/navigation
const handleRowClick = (campaign: any) => {
router.push(`/phishing/tickets/${campaign.firstReportTicketId}`);
};
```
**DataTable usage + empty state** — `components/admin/DataTable.tsx` confirmed props (`columns`, `data`, `totalCount`, `page`, `pageSize`, `onPageChange`, `onRowClick`); wrap in `EmptyState` (`components/ui/empty-state.tsx`, read in full) when `items.length === 0`:
```typescript
<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />
```
---
### `app/phishing/tickets/[ticketId]/page.tsx` (component/page, request-response)
**Analog:** `app/analyzer/reports/[id]/page.tsx` (read lines 1-170) — closest existing `'use client'` + `use(params)` + polling-capable fetch + Card-stack layout + loading/error early-return pattern.
**Imports + params pattern** (lines 1-16 of analog):
```typescript
'use client';
import { useEffect, useState, use } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useSession } from '@/lib/auth-client';
import { hasPermission } from '@/lib/permissions';
export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) {
const { ticketId } = use(params);
```
**Fetch + loading/error state pattern** (lines 129-166 of analog — adapt to the two-step fetch: resolver then campaign detail):
```typescript
const [state, setState] = useState<'loading' | 'not-triaged' | 'ungrouped' | 'ready' | 'error'>('loading');
const [campaign, setCampaign] = useState<CampaignDetail | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
setState('loading');
try {
const resolveRes = await fetch(`/api/phishing/tickets/${ticketId}/campaign`);
const resolved = await resolveRes.json();
if (!resolved.found) { setState('not-triaged'); return; }
if (!resolved.campaignId) { setState('ungrouped'); /* fetch report-only evidence */ return; }
const detailRes = await fetch(`/api/phishing/campaigns/${resolved.campaignId}`);
if (!detailRes.ok) throw new Error(`Request failed: ${detailRes.status}`);
const data = await detailRes.json();
setCampaign(data);
setState('ready');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setState('error');
}
}
useEffect(() => { void load(); }, [ticketId]);
```
D-04's "refetch after action" reuses this same `load()` function — pass it down to `ActionAreaCard` as an `onActionComplete` callback.
**Client-side permission gate** (no existing call site — first instance in codebase; synthesized from `components/rmm/rmm-dispatch-dialog.tsx` lines 50-52 `useSession()` shape + confirmed `hasPermission()` signature in `lib/permissions.ts`):
```typescript
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canApprove = hasPermission(role, 'phishing', 'approve');
const canRemediate = hasPermission(role, 'phishing', 'remediate');
```
**Loading/error early-return pattern** (lines 162-166 of analog, adapted to `SkeletonHeader`+`SkeletonCard` per UI-SPEC rather than plain text):
```typescript
if (state === 'loading') return <><SkeletonHeader /><SkeletonCard /><SkeletonCard /><SkeletonCard /></>;
if (state === 'error') return <Alert variant="destructive">{error} <Button onClick={load}>Retry</Button></Alert>;
```
---
### `components/phishing/action-area-card.tsx` (component, event-driven)
**Analog:** `components/rmm/rmm-dispatch-dialog.tsx` (read in full, 190 lines) — closest existing client-side permission-gated dispatch-and-toast component.
**Permission + disabled-button pattern** (lines 50-52, 108-112, 125-134 of analog):
```typescript
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canExecute = role === 'admin' || role === 'super-admin'; // replace with hasPermission() per D-06
const disabledReason = !canExecute ? 'Requires admin' : !deviceUid ? 'No Datto device id' : null;
<Button
variant="outline"
size="sm"
disabled={!!disabledReason}
title={disabledReason ?? 'Dispatch a Datto RMM script for this device'}
>
```
Replace `title` attribute with the new shadcn `Tooltip` primitive per UI-SPEC (D-05/D-06 requires a `Tooltip`, not a bare `title` — this is the one place this phase's pattern diverges from the analog).
**Fetch-POST-toast pattern** (lines 77-105 of analog — copy structure exactly for approve/remediate/mark-false-positive):
```typescript
async function dispatch() {
try {
const res = await fetch(`/api/phishing/campaigns/${campaignId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions: checkedActions }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Approve failed');
toast.success(`Approved ${data.length} remediation action(s)`);
onActionComplete(); // D-04: refetch, no optimistic mutation
} catch (err) {
toast.error(`Approve failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
```
**Request body shape (must match exactly)** — from `lib/services/remediation-service.ts` `ApproveActionInput`:
```typescript
interface ApproveActionInput { actionType: string; params?: Record<string, unknown>; }
// POST body: { actions: ApproveActionInput[] }
```
---
### `components/phishing/classification-card.tsx` / `evidence-card.tsx` / `timeline-card.tsx` (components, read-only render)
**Analog:** `app/analyzer/reports/[id]/page.tsx`'s inline Card sections (lines 169-277+, e.g. the Categories/Root cause cards at lines 261-276) — the `<Card><CardHeader><CardTitle>...</CardTitle></CardHeader><CardContent>...</CardContent></Card>` nesting idiom, plus `MiniBars`-style small presentational sub-components (lines 98-120).
**Card shell pattern** (lines 261-268 of analog):
```typescript
<Card>
<CardHeader>
<CardTitle className="font-bold">{/* explicit font-bold override per UI-SPEC Typography note — CardTitle defaults to font-semibold */}
<Sparkles className="h-4 w-4 mr-2 inline" /> Classification
</CardTitle>
</CardHeader>
<CardContent>
{/* content */}
</CardContent>
</Card>
```
**Tabs pattern for `evidence-card.tsx`** — no existing phishing tabs precedent; use `components/ui/tabs.tsx` primitive directly (already in the repo, confirmed by UI-SPEC's Component Inventory) with the same `<Tabs><TabsList><TabsTrigger>` shadcn idiom used elsewhere (e.g. search `grep -rl "TabsTrigger" components/`); no phishing-specific analog needed since this is a direct shadcn composition, not a bespoke pattern.
**StatusBadge usage for verdict/SPF/DKIM/etc.** — `components/ui/status-badge.tsx` (read in full, 76 lines):
```typescript
<StatusBadge variantClass="bg-destructive/15 text-destructive">THREAT</StatusBadge>
// or tone-based:
<StatusBadge tone="success">pass</StatusBadge>
```
---
### `components/phishing/url-list.tsx` (component, D-09 inert render)
**No direct analog** — this is new codebase-first territory (no existing "extracted URL, inert" component exists). Build directly from UI-SPEC's Evidence Display Spec §2 (exact code given there):
```typescript
<code className="font-mono text-xs truncate">{url}</code>
<Button size="icon" variant="ghost" aria-label="Copy URL" onClick={() => { navigator.clipboard.writeText(url); toast.success('Copied'); }}>
<Copy className="h-3.5 w-3.5" />
</Button>
```
**Critical constraint (D-09):** never wrap in `<a>`/`href`, never attach an `onClick` that navigates — copy-to-clipboard only. This is the one component where "no existing analog" is itself the point: `triage-note-service.ts`'s sanitization approach (strips query strings, still describes in prose) must NOT be copied here — this is stricter (fully inert).
---
### `components/navigation/app-navigation.tsx` (MODIFY)
**Analog:** itself — the existing `navigationItems` array (lines 51-202, read in full). The `PAX8` entry (lines 64-69) is the exact shape to copy per UI-SPEC's explicit instruction (flat top-level item, no children):
```typescript
{
title: 'PAX8',
href: '/pax8',
icon: ShoppingCart,
description: 'PAX8 companies, subscriptions, and cost breakdown'
},
```
New entry (insert immediately after PAX8, before "Backup Status", per UI-SPEC D-02):
```typescript
{
title: 'Phishing',
href: '/phishing',
icon: ShieldAlert,
description: 'Phishing/spam campaign triage, evidence, and remediation approval',
},
```
Add `ShieldAlert` to the existing `lucide-react` import block (line 6-22). No entry needed in `visibleItems` role-filter (lines 219-224) — Phishing is visible to all roles (every role has `phishing: ["read"]` minimum), unlike Engagement/Admin which are super-admin-gated.
---
## Shared Patterns
### Auth (server-side, every new/extended route)
**Source:** `lib/auth-utils.ts`'s `requirePermission`, used identically across all 6 existing phishing routes (`campaigns/[id]/route.ts` line 62, `approve/route.ts` line 28, `remediate/route.ts` line 27, `mark-false-positive/route.ts` line 27, `classify/route.ts` line 24, `analyze/route.ts` line 22).
**Apply to:** the new ticket→campaign resolver route (`requirePermission('phishing', 'read')`), no other new routes needed (approve/remediate/mark-false-positive/classify already exist and are reused verbatim).
```typescript
const { error } = await requirePermission('phishing', 'read');
if (error) return error;
```
### UUID validation (every `[id]`-scoped route)
**Source:** identical `UUID_RE` constant duplicated verbatim across all 5 existing `campaigns/[id]/**` routes:
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
```
**Apply to:** No new `[id]`-scoped route is introduced this phase (the campaign detail route is extended, not new) — this pattern is reference-only for consistency-checking the extension didn't break it. The new ticket resolver route uses `Number.isFinite(ticketId)` instead (matches `analyze/route.ts`'s idiom, not the UUID one).
### Error handling (every route in this phase)
**Source:** identical `try/catch` + `console.error('[TAG] message', id, err)` + `NextResponse.json({ error, message }, { status: 500 })` shape, verbatim across all 6 read routes.
**Apply to:** the new resolver route, and the extended detail/list routes (do not change the existing catch blocks — only widen the `try` body).
### Client-side permission gating (D-06/REVIEW-06) — first instance in this codebase
**Source:** synthesized from `lib/permissions.ts`'s confirmed-pure `hasPermission(roleName, resource, action)` + `components/rmm/rmm-dispatch-dialog.tsx`'s confirmed `useSession()` usage (lines 50-52). **No single file contains this exact call today** — this phase establishes the pattern.
**Apply to:** `app/phishing/tickets/[ticketId]/page.tsx`, `components/phishing/action-area-card.tsx`, `components/phishing/classification-card.tsx` (Reclassify button).
```typescript
import { useSession } from '@/lib/auth-client';
import { hasPermission } from '@/lib/permissions';
const { data: session } = useSession();
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
const canApprove = hasPermission(role, 'phishing', 'approve');
```
### Refetch-after-action, no optimistic mutation (D-04)
**Source:** No existing exact analog (most write-then-refetch flows in this codebase use React Query-style invalidation, absent here) — established directly by CONTEXT.md D-04. Structurally similar to `app/analyzer/reports/[id]/page.tsx`'s own re-poll-on-timer pattern (lines 146-148: `if (report.status === 'pending' ...) timer = setTimeout(load, 3000)`), reused here as "call `load()` again after a 2xx POST" rather than on a timer.
**Apply to:** all three action buttons in `action-area-card.tsx`.
### Toast feedback
**Source:** `sonner`'s `toast.success()`/`toast.error()`, used identically in `components/rmm/rmm-dispatch-dialog.tsx` lines 99, 101.
**Apply to:** all three action buttons, plus the D-07 "Analyze this ticket" CTA and the Reclassify button.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `components/phishing/timeline-card.tsx` | component | transform (merge 3 sources + render) | No existing "vertical timeline merging 3 heterogeneous sources" component in Pulse — UI-SPEC prescribes plain `div`s + Tailwind (explicitly "not a reusable pattern elsewhere in Pulse yet"), build fresh per the UI-SPEC's Timeline Spec section. |
| `components/phishing/url-list.tsx` | component | transform (inert render) | No existing "extracted-URL-as-inert-text" component; D-09 is a deliberately stricter posture than any existing sanitization precedent (Phase 21's triage-note prose sanitization). Build fresh from UI-SPEC's exact code block. |
| `components/ui/tooltip.tsx` | component (shadcn primitive) | n/a | Confirmed absent from `components/ui/` via grep — generate via `npx shadcn add tooltip`, not hand-written; no "analog" applies to a registry-generated file. |
## Metadata
**Analog search scope:** `app/api/phishing/**`, `lib/services/*.ts` (phishing/mimecast/triage-note/remediation/audit/classifier), `lib/permissions.ts`, `lib/auth-client.ts`, `lib/auth-utils.ts`, `components/navigation/**`, `components/rmm/rmm-dispatch-dialog.tsx`, `components/admin/DataTable.tsx`, `components/ui/{status-badge,empty-state,skeleton-helpers,card}.tsx`, `app/analyzer/reports/[id]/page.tsx`, `app/admin/data-browser/companies/page.tsx`
**Files scanned:** ~24 read in full or targeted ranges
**Pattern extraction date:** 2026-07-16