# 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
