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
This commit is contained in:
lorentz 2026-07-11 14:28:11 -04:00
parent d2dfc341da
commit 443b6ce75b

View file

@ -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<string, string> = {
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<CompanyRow>(
`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 }
);
}
}