diff --git a/app/(dashboard)/settings/suppressions/page.tsx b/app/(dashboard)/settings/suppressions/page.tsx index add417d..59d48d1 100644 --- a/app/(dashboard)/settings/suppressions/page.tsx +++ b/app/(dashboard)/settings/suppressions/page.tsx @@ -81,6 +81,7 @@ export default function SuppressionsPage() { const [page, setPage] = useState(1) const [total, setTotal] = useState(0) const [search, setSearch] = useState("") + const [reasonFilter, setReasonFilter] = useState("all") const [addOpen, setAddOpen] = useState(false) const [addEmail, setAddEmail] = useState("") @@ -97,6 +98,7 @@ export default function SuppressionsPage() { limit: String(PAGE_SIZE), }) if (search) params.set("search", search) + if (reasonFilter !== "all") params.set("reason", reasonFilter) const res = await fetch(`/api/internal/suppressions?${params.toString()}`) if (!res.ok) throw new Error() const json = (await res.json()) as ListResponse @@ -107,7 +109,7 @@ export default function SuppressionsPage() { } finally { setLoading(false) } - }, [page, search, toast]) + }, [page, search, reasonFilter, toast]) useEffect(() => { fetchData() }, [fetchData]) @@ -228,6 +230,25 @@ export default function SuppressionsPage() { }} className="max-w-sm" /> +

{total.toLocaleString()} total

@@ -237,7 +258,7 @@ export default function SuppressionsPage() { ) : data.length === 0 ? (
- {search ? "No suppressions match your search." : "No suppressions yet."} + {search || reasonFilter !== "all" ? "No suppressions match your filters." : "No suppressions yet."}
) : ( <> diff --git a/app/api/internal/suppressions/route.ts b/app/api/internal/suppressions/route.ts index ac83cf1..2f50d0f 100644 --- a/app/api/internal/suppressions/route.ts +++ b/app/api/internal/suppressions/route.ts @@ -1,20 +1,18 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { suppressions } from '@/lib/db/schema' -import { ilike, sql, desc } from 'drizzle-orm' +import { sql, desc } from 'drizzle-orm' +import { parseSuppressionListQuery, suppressionListWhere } from '@/lib/suppressions/list-query' import { createSuppressionSchema } from '@/lib/validations/suppressions' import { suppressEmail, normalizeEmail } from '@/lib/suppressions' import { auditFromSession, logAudit } from '@/lib/audit' import { requireAdmin } from '@/lib/auth-helpers' export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url) - const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) - const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 - const limit = Math.min(200, Math.max(1, limitRaw)) - const search = (searchParams.get('search') ?? '').trim() - - const where = search ? ilike(suppressions.email, `%${search.toLowerCase()}%`) : undefined + const parsed = parseSuppressionListQuery(new URL(req.url).searchParams) + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }) + const { page, limit } = parsed.query + const where = suppressionListWhere(parsed.query) const dataQuery = db .select() diff --git a/app/api/v1/suppressions/route.ts b/app/api/v1/suppressions/route.ts index 47f79d4..160c898 100644 --- a/app/api/v1/suppressions/route.ts +++ b/app/api/v1/suppressions/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { suppressions } from '@/lib/db/schema' -import { ilike, sql, desc } from 'drizzle-orm' +import { sql, desc } from 'drizzle-orm' +import { parseSuppressionListQuery, suppressionListWhere } from '@/lib/suppressions/list-query' import { withApiAuth } from '@/lib/api-auth' import { createSuppressionSchema } from '@/lib/validations/suppressions' import { suppressEmail, normalizeEmail } from '@/lib/suppressions' @@ -9,13 +10,10 @@ import { auditFromApiKey, logAudit } from '@/lib/audit' export async function GET(req: NextRequest) { return withApiAuth(req, async () => { - const { searchParams } = new URL(req.url) - const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) - const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 - const limit = Math.min(200, Math.max(1, limitRaw)) - const search = (searchParams.get('search') ?? '').trim() - - const where = search ? ilike(suppressions.email, `%${search.toLowerCase()}%`) : undefined + const parsed = parseSuppressionListQuery(new URL(req.url).searchParams) + if (!parsed.ok) return NextResponse.json({ error: parsed.error, data: null, meta: {} }, { status: 400 }) + const { page, limit } = parsed.query + const where = suppressionListWhere(parsed.query) const dataQuery = db .select() diff --git a/lib/suppressions/list-query.ts b/lib/suppressions/list-query.ts new file mode 100644 index 0000000..558f79a --- /dev/null +++ b/lib/suppressions/list-query.ts @@ -0,0 +1,43 @@ +import { and, eq, ilike, type SQL } from 'drizzle-orm' +import { suppressions } from '@/lib/db/schema' +import { SUPPRESSION_REASONS } from '@/lib/validations/suppressions' + +export type SuppressionReasonFilter = (typeof SUPPRESSION_REASONS)[number] + +export interface SuppressionListQuery { + page: number + limit: number + search: string + reason: SuppressionReasonFilter | null +} + +export type ParsedSuppressionListQuery = + | { ok: true; query: SuppressionListQuery } + | { ok: false; error: string } + +/** + * Parse `page`, `limit`, `search` and `reason` from a suppressions list + * request. An unknown `reason` is an error rather than "no filter", so a + * typo never silently returns every suppression. + */ +export function parseSuppressionListQuery(searchParams: URLSearchParams): ParsedSuppressionListQuery { + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) + const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 + const limit = Math.min(200, Math.max(1, limitRaw)) + const search = (searchParams.get('search') ?? '').trim() + const rawReason = (searchParams.get('reason') ?? '').trim().toLowerCase() + if (rawReason && rawReason !== 'all' && !(SUPPRESSION_REASONS as readonly string[]).includes(rawReason)) { + return { ok: false, error: `Unknown reason "${rawReason}". Use one of: ${SUPPRESSION_REASONS.join(', ')}.` } + } + const reason = rawReason && rawReason !== 'all' ? (rawReason as SuppressionReasonFilter) : null + return { ok: true, query: { page, limit, search, reason } } +} + +/** The WHERE clause for a list query; undefined when nothing is filtered. */ +export function suppressionListWhere(query: Pick): SQL | undefined { + const conditions: SQL[] = [] + if (query.search) conditions.push(ilike(suppressions.email, `%${query.search.toLowerCase()}%`)) + if (query.reason) conditions.push(eq(suppressions.reason, query.reason)) + if (conditions.length === 0) return undefined + return conditions.length === 1 ? conditions[0] : and(...conditions) +} diff --git a/test/suppression-list-query.test.ts b/test/suppression-list-query.test.ts new file mode 100644 index 0000000..f6acde1 --- /dev/null +++ b/test/suppression-list-query.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parseSuppressionListQuery } from '@/lib/suppressions/list-query' + +const parse = (qs: string) => parseSuppressionListQuery(new URLSearchParams(qs)) + +describe('parseSuppressionListQuery', () => { + it('defaults to the first page of 50 with no filters', () => { + assert.deepEqual(parse(''), { ok: true, query: { page: 1, limit: 50, search: '', reason: null } }) + }) + + it('accepts a known reason, case-insensitively, and "all" as no filter', () => { + assert.deepEqual(parse('reason=Bounce'), { ok: true, query: { page: 1, limit: 50, search: '', reason: 'bounce' } }) + assert.deepEqual(parse('reason=all'), { ok: true, query: { page: 1, limit: 50, search: '', reason: null } }) + }) + + it('rejects an unknown reason instead of returning everything', () => { + const parsed = parse('reason=bounced') + assert.equal(parsed.ok, false) + if (!parsed.ok) assert.match(parsed.error, /Unknown reason "bounced"/) + }) + + it('clamps page and limit and trims the search', () => { + assert.deepEqual(parse('page=-3&limit=999&search=%20a%40b.com%20'), { + ok: true, + query: { page: 1, limit: 200, search: 'a@b.com', reason: null }, + }) + }) +})