From 443b6ce75b047c3d263d97b22d60eeaf50ef4254 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:28:11 -0400 Subject: [PATCH] feat(14-01): add paginated PAX8 companies list route - GET /api/pax8/companies with requireAuth gate (D-07) - Whitelisted sort columns, parameterized search/limit/offset - Joins pax8_companies to companies for matched name + active subscription count --- app/api/pax8/companies/route.ts | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 app/api/pax8/companies/route.ts diff --git a/app/api/pax8/companies/route.ts b/app/api/pax8/companies/route.ts new file mode 100644 index 0000000..d2f2049 --- /dev/null +++ b/app/api/pax8/companies/route.ts @@ -0,0 +1,117 @@ +/** + * GET /api/pax8/companies + * Returns a paginated, sortable, searchable list of PAX8 companies with + * their matched Autotask company name and active subscription count. + * Query params: + * limit (default 50, max 200) + * offset (default 0) + * sort (name | status | city | country | subscriptions | match) + * order (asc | desc, default asc) + * search (matches pc.name via ILIKE) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface CompanyRow { + id: string; + name: string; + status: string | null; + city: string | null; + state_or_province: string | null; + country: string | null; + autotask_company_id: string | null; + match_confidence: string | null; + match_method: string | null; + matched_company_name: string | null; + active_subscription_count: string; +} + +// Whitelisted sort columns — never interpolate the raw `sort` query param +// into SQL (T-14-04). +const SORT_COLUMNS: Record = { + name: 'pc.name', + status: 'pc.status', + city: 'pc.city', + country: 'pc.country', + subscriptions: 'active_subscription_count', + match: 'pc.match_method', +}; + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + 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 sortParam = url.searchParams.get('sort') ?? ''; + const orderParam = url.searchParams.get('order') ?? ''; + const search = url.searchParams.get('search'); + + const sortColumn = SORT_COLUMNS[sortParam] ?? SORT_COLUMNS.name; + const sortOrder = orderParam.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + + const params: unknown[] = [limit, offset]; + let searchFilter = ''; + if (search) { + params.push(`%${search}%`); + searchFilter = `AND pc.name ILIKE $${params.length}`; + } + + const companies = await postgresClient.query( + `SELECT pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country, + pc.autotask_company_id::text AS autotask_company_id, + pc.match_confidence::text AS match_confidence, pc.match_method, + c.company_name AS matched_company_name, + (SELECT count(*) FROM pax8_subscriptions s + WHERE s.pax8_company_id = pc.id AND s.is_deleted = false + AND s.status = 'Active')::text AS active_subscription_count + FROM pax8_companies pc + LEFT JOIN companies c ON c.id = pc.autotask_company_id + WHERE pc.is_deleted = false + ${searchFilter} + ORDER BY ${sortColumn} ${sortOrder} + LIMIT $1 OFFSET $2`, + params + ); + + const totalParams: unknown[] = []; + let totalSearchFilter = ''; + if (search) { + totalParams.push(`%${search}%`); + totalSearchFilter = `AND pc.name ILIKE $${totalParams.length}`; + } + const totalRes = await postgresClient.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM pax8_companies pc + WHERE pc.is_deleted = false ${totalSearchFilter}`, + totalParams + ); + const total = parseInt(totalRes.rows[0]?.count ?? '0', 10); + + const items = companies.rows.map((row) => ({ + id: row.id, + name: row.name, + status: row.status, + city: row.city, + stateOrProvince: row.state_or_province, + country: row.country, + autotaskCompanyId: row.autotask_company_id !== null ? Number(row.autotask_company_id) : null, + matchConfidence: row.match_confidence !== null ? Number(row.match_confidence) : null, + matchMethod: row.match_method, + matchedCompanyName: row.matched_company_name, + activeSubscriptionCount: Number(row.active_subscription_count), + })); + + return NextResponse.json({ items, total, limit, offset }); + } catch (err) { + console.error('Failed to fetch PAX8 companies:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch PAX8 companies' }, + { status: 500 } + ); + } +}