From c852cfee13b5146ba2d0f443e193120ec813bbe4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 19:31:19 -0400 Subject: [PATCH] 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 } + ); + } +}