From c852cfee13b5146ba2d0f443e193120ec813bbe4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:31:19 -0400 Subject: [PATCH 1/3] feat(18-03): add GET /api/phishing/campaigns paginated list - requirePermission('phishing','read') gate (ACCESS-01) - limit/offset clamped, optional status filter via parameterized $n placeholder (never string-interpolated) - camelCase response { items, total, limit, offset } --- app/api/phishing/campaigns/route.ts | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 app/api/phishing/campaigns/route.ts diff --git a/app/api/phishing/campaigns/route.ts b/app/api/phishing/campaigns/route.ts new file mode 100644 index 0000000..56e8c04 --- /dev/null +++ b/app/api/phishing/campaigns/route.ts @@ -0,0 +1,77 @@ +/** + * GET /api/phishing/campaigns + * Returns a paginated list of phishing campaigns. + * Query params: + * limit (default 50, max 200) + * offset (default 0) + * status (optional filter, e.g. 'open') + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface CampaignRow { + id: string; + campaign_key: string | null; + group_method: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + report_count: number; + status: string; + created_at: string; +} + +export async function GET(request: NextRequest) { + const { error } = await requirePermission('phishing', 'read'); + if (error) return error; + + try { + const url = request.nextUrl; + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200); + const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0); + const status = url.searchParams.get('status'); + + const params: unknown[] = [limit, offset]; + let statusFilter = ''; + if (status) { + params.push(status); + statusFilter = `WHERE 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 + ${statusFilter} + ORDER BY last_seen_at DESC NULLS LAST + LIMIT $1 OFFSET $2`, + params + ); + + const totalRes = await postgresClient.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM campaigns ${statusFilter}`, + status ? [status] : [] + ); + const total = parseInt(totalRes.rows[0]?.count ?? '0', 10); + + const items = campaigns.rows.map((c) => ({ + id: c.id, + campaignKey: c.campaign_key, + groupMethod: c.group_method, + firstSeenAt: c.first_seen_at, + lastSeenAt: c.last_seen_at, + reportCount: c.report_count, + status: c.status, + createdAt: c.created_at, + })); + + return NextResponse.json({ items, total, limit, offset }); + } catch (err) { + console.error('[PHISHING-CAMPAIGNS] Failed to list campaigns', err); + return NextResponse.json( + { error: 'Failed to list campaigns', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} From 959907d63bf127223e05099c9c9cd12dd5b955e6 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:31:35 -0400 Subject: [PATCH 2/3] feat(18-03): add GET /api/phishing/campaigns/[id] nested detail - requirePermission('phishing','read') gate (ACCESS-01) - UUID-validated id (400 on malformed), 404 when campaign absent - bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern) - requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column) - messages.subject pulled from headers->>'subject' JSONB (no subject column) - classifications included in shape (Phase 19 stub, expected empty) --- app/api/phishing/campaigns/[id]/route.ts | 173 +++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/route.ts diff --git a/app/api/phishing/campaigns/[id]/route.ts b/app/api/phishing/campaigns/[id]/route.ts new file mode 100644 index 0000000..29808e3 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/route.ts @@ -0,0 +1,173 @@ +/** + * GET /api/phishing/campaigns/[id] + * Returns a single campaign with nested linked reports, messages, + * indicators, and classification history. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface CampaignRow { + id: string; + campaign_key: string | null; + group_method: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + report_count: number; + status: string; + created_at: string; + updated_at: string; +} + +interface ReportRow { + id: string; + ticket_id: string; + ticket_number: string | null; + company_name: string | null; + title: string | null; + created_at: string; + requester_email: string | null; +} + +interface MessageRow { + id: string; + report_id: string; + message_id: string | null; + subject: string | null; +} + +interface IndicatorRow { + id: string; + message_id: string; + indicator_type: string; + value: string; + metadata: unknown; +} + +interface ClassificationRow { + id: string; + verdict: string; + confidence: string | null; + summary: string | null; + created_at: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requirePermission('phishing', 'read'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed UUID would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + try { + const campaignRes = await postgresClient.query( + `SELECT id::text, campaign_key, group_method, first_seen_at::text, + last_seen_at::text, report_count, status, created_at::text, updated_at::text + FROM campaigns WHERE id = $1`, + [id] + ); + const campaign = campaignRes.rows[0]; + if (!campaign) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + // Bulk-fetch linked reports (+ join contacts for requester email — campaigns + // has no recipients column, must be derived via this join). + const reportsRes = await postgresClient.query( + `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] + ); + 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. + const messagesRes = reportIds.length + ? await postgresClient.query( + `SELECT id::text, report_id::text, message_id, headers->>'subject' AS subject + FROM messages WHERE report_id = ANY($1::uuid[])`, + [reportIds] + ) + : { rows: [] as MessageRow[] }; + const messageIds = messagesRes.rows.map((m) => m.id); + + // Bulk-fetch indicators keyed by the message-id array. + const indicatorsRes = messageIds.length + ? await postgresClient.query( + `SELECT id::text, message_id::text, indicator_type, value, metadata + FROM indicators WHERE message_id = ANY($1::uuid[])`, + [messageIds] + ) + : { rows: [] as IndicatorRow[] }; + + // Classifications (Phase 19 stub — likely empty this phase, still + // included in the response shape per CAMP-03). + const classificationsRes = await postgresClient.query( + `SELECT id::text, verdict, confidence, summary, created_at::text + FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC`, + [id] + ); + + return NextResponse.json({ + id: campaign.id, + campaignKey: campaign.campaign_key, + groupMethod: campaign.group_method, + firstSeenAt: campaign.first_seen_at, + lastSeenAt: campaign.last_seen_at, + reportCount: campaign.report_count, + 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, + })), + }); + } 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 } + ); + } +} From 86f5166a864919ad07e0501ab813424a1982e903 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:32:31 -0400 Subject: [PATCH 3/3] docs(18-03): complete campaign read API plan Co-Authored-By: Claude Sonnet 5 --- .../18-03-SUMMARY.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .planning/phases/18-campaign-grouping-phishing-analysis-api/18-03-SUMMARY.md diff --git a/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-03-SUMMARY.md b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-03-SUMMARY.md new file mode 100644 index 0000000..dd637a0 --- /dev/null +++ b/.planning/phases/18-campaign-grouping-phishing-analysis-api/18-03-SUMMARY.md @@ -0,0 +1,111 @@ +--- +phase: 18-campaign-grouping-phishing-analysis-api +plan: 03 +subsystem: api +tags: [postgres, phishing, campaigns, better-auth, next-js-routes] + +# Dependency graph +requires: + - phase: 18-campaign-grouping-phishing-analysis-api (Plan 01) + provides: "phishing permission resource in lib/permissions.ts (requirePermission('phishing','read') type-checks and enforces role grants)" +provides: + - "GET /api/phishing/campaigns — paginated camelCase campaign list (CAMP-03)" + - "GET /api/phishing/campaigns/{id} — nested camelCase campaign detail with reports/messages/indicators/classifications (CAMP-03)" +affects: [19-classification, 20-remediation, 21-autotask-triage-note] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Bulk-fetch nested detail via ANY($1::uuid[]) keyed by parent id array, assembled with plain .map() (device-link-conflicts pattern) — no ORM, no ad-hoc joins across 4 levels" + - "Parameterized status/filter values via $n placeholder index, never string-interpolated into SQL text" + +key-files: + created: + - app/api/phishing/campaigns/route.ts + - app/api/phishing/campaigns/[id]/route.ts + modified: [] + +key-decisions: + - "messages.subject is derived at query time via headers->>'subject' JSONB extraction since messages has no subject column (per migrations/097 schema + 18-PATTERNS.md note)" + - "requesterEmail on each report is derived via a LEFT JOIN reports.requester_contact_id -> contacts.email_address, since campaigns/reports have no recipients column" + - "classifications are queried and included in the detail response shape even though the classifications table is expected empty this phase (Phase 19 stub) — per CAMP-03's requirement to expose the shape now" + - "Child bulk-fetch queries (messages, indicators) are skipped entirely when the parent id array is empty, avoiding an unnecessary ANY($1::uuid[]) call with an empty array" + +patterns-established: + - "Both list and detail phishing routes gate with a first-line requirePermission('phishing','read') call-and-early-return (D-06), matching every other route in this phase and the wider codebase convention" + +requirements-completed: [CAMP-03, ACCESS-01] + +# Metrics +duration: 20min +completed: 2026-07-15 +--- + +# Phase 18 Plan 03: Campaign Read API (List + Detail) Summary + +**Two new GET routes — `/api/phishing/campaigns` (paginated camelCase list) and `/api/phishing/campaigns/{id}` (nested detail with linked reports/messages/indicators/classifications) — both gated by `requirePermission('phishing','read')`.** + +## Performance + +- **Duration:** ~20 min +- **Started:** 2026-07-15T23:15Z (approx) +- **Completed:** 2026-07-15T23:31Z +- **Tasks:** 2/2 completed +- **Files modified:** 2 (both created) + +## Accomplishments +- `app/api/phishing/campaigns/route.ts` — paginated `GET` returning `{ items, total, limit, offset }` in camelCase (id, campaignKey, groupMethod, firstSeenAt, lastSeenAt, reportCount, status, createdAt). `limit` clamped to `[1,200]` (default 50), `offset` clamped to `>= 0` (default 0), optional `status` filter bound via a `$n` placeholder (never string-interpolated). +- `app/api/phishing/campaigns/[id]/route.ts` — `GET` returning one campaign with nested `reports[]` (+ `requesterEmail` via `contacts` join), `messages[]` (+ `subject` via `headers->>'subject'`), `indicators[]`, and `classifications[]`, all in camelCase. UUID-validates the path param (400 on malformed id before any query), 404 when the campaign is absent. Children are bulk-fetched via `= ANY($1::uuid[])` keyed by the parent id array (device-link-conflicts pattern), with an empty-array short-circuit so no query runs when there are zero reports/messages. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: GET /api/phishing/campaigns paginated list** - `c852cfe` (feat) +2. **Task 2: GET /api/phishing/campaigns/[id] nested detail** - `959907d` (feat) + +**Plan metadata:** committed alongside this SUMMARY (final commit in this plan's history) + +## Files Created/Modified +- `app/api/phishing/campaigns/route.ts` - `GET` handler: requirePermission gate, clamp limit/offset, optional parameterized status filter, COUNT(*) total, camelCase `.map()` +- `app/api/phishing/campaigns/[id]/route.ts` - `GET` handler: requirePermission gate, UUID regex validation (400), parent campaign lookup (404 if absent), bulk-fetch reports (+ contacts join for requesterEmail) / messages (+ headers->>'subject') / indicators / classifications, camelCase nested assembly + +## Decisions Made +- Followed 18-PATTERNS.md section 4/5 code shapes essentially verbatim — no deviation in query structure or response shape from the pattern map's "Exact shape to replicate" blocks. +- Confirmed `messages` has no `subject` column (checked `migrations/097_phishing_triage_schema.sql` directly) before writing the query, per the plan's explicit warning that a naive `SELECT subject` would fail — used `headers->>'subject' AS subject` instead. +- Both routes wrap all Postgres work in try/catch returning 500 `{ error, message }` on unexpected failure, matching `device-link-conflicts/route.ts`'s error-handling convention (not shown in the pattern map's illustrative snippets, but required by CLAUDE.md's API route conventions and consistent with Task 1's stated acceptance criteria). + +## Deviations from Plan + +**1. [Manual verification not performed against a live server]** +- **Found during:** Task 1 and Task 2 verification step +- **Issue:** Both tasks' `` blocks specify a `` step (curl against `localhost:3100` with an admin session cookie to confirm 200/401/400/404 responses). The dev server currently listening on port 3100 in this sandbox is a long-running Next.js process bound to the main repository checkout (`next-server`, running since before this session started), not this git worktree — it does not reflect the two new route files created here. Restarting or repointing that shared server risked disrupting other concurrent worktree agents in the same wave. +- **Resolution:** Performed the automated verification (`npx tsc --noEmit --pretty`, exit 0, both files) and re-derived every acceptance criterion via targeted `grep` (requirePermission call sites, `$n` placeholder, `Math.min` clamp, `ANY($1::uuid[])` bulk-fetch, UUID regex, `requester_contact_id` join, `headers->>'subject'`) — all confirmed present. The live curl-based 200/401/400/404 check is deferred to the orchestrator's post-wave verification pass (or a future manual QA pass), since it requires a server instance actually running this worktree's code plus a real campaign UUID and an authenticated session cookie, neither of which are safely producible from this isolated worktree without touching shared infrastructure. +- **Files modified:** none (documentation-only deviation, no code change) +- **Impact:** Both routes are believed correct per code review against the pattern map and the type checker; the manual HTTP-level check is the one open item, not a code defect. + +--- + +**Total deviations:** 1 (verification method substitution, no code impact) +**Impact on plan:** No scope creep, no code changes beyond the plan's spec. The only deviation is in how the manual verification step was satisfied (static analysis + grep instead of live curl), due to the shared-server constraint of the worktree execution model. + +## Issues Encountered +None beyond the verification-method note above. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Both campaign read routes are live and ready for Phase 19 (classification) and Phase 20 (remediation/approval) to build write-path routes alongside them under the same `/api/phishing/*` namespace and `requirePermission('phishing', ...)` convention. +- The detail route's `classifications[]` array is wired to query the `classifications` table now — Phase 19 can start writing rows to it with zero changes needed on this route. +- Recommended follow-up (not blocking): once a real campaign exists in a running environment, a human or the phase verifier should do the one live curl pass (200/401/400/404) that this plan's `` originally called for. + +--- +*Phase: 18-campaign-grouping-phishing-analysis-api* +*Completed: 2026-07-15* + +## Self-Check: PASSED + +Both created files confirmed present on disk; both task commit hashes (`c852cfe`, `959907d`) confirmed present in git log.