chore: merge executor worktree (worktree-agent-a7f5ed0d03f07bb8a)
This commit is contained in:
commit
57db03b9ca
3 changed files with 450 additions and 0 deletions
120
.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md
Normal file
120
.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
---
|
||||||
|
phase: 14-pax8-ui-surface
|
||||||
|
plan: 01
|
||||||
|
subsystem: api
|
||||||
|
tags: [postgres, next.js, pax8, api-route, requireAuth]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 12-orders-invoices-company-matching
|
||||||
|
provides: pax8_order_items historical cost data, pax8_companies.autotask_company_id matching
|
||||||
|
provides:
|
||||||
|
- "GET /api/pax8/companies — paginated/sortable/searchable PAX8 company list with matched Autotask name and active subscription count"
|
||||||
|
- "GET /api/pax8/companies/[id] — single company subscriptions + per-subscription latest-billed cost breakdown, windowed per subscription_id"
|
||||||
|
affects: ["14-04 (the /pax8 page that consumes these routes)"]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Whitelisted ORDER BY column map for user-controllable sort params (never interpolate raw sort/order strings into SQL)"
|
||||||
|
- "DISTINCT ON (subscription_id) windowed query for per-entity 'latest' lookups instead of a single global MAX cutoff"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- app/api/pax8/companies/route.ts
|
||||||
|
- app/api/pax8/companies/[id]/route.ts
|
||||||
|
modified: []
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Both routes gate with requireAuth() only (D-07) — no admin/permission check, since this is read-only view data any authenticated manager may see"
|
||||||
|
- "Cost breakdown uses order-item line_total as the billed amount, falling back to price*quantity only when no order-item row exists for a subscription (Pitfall 3)"
|
||||||
|
- "Order-item rows whose subscription_id has no matching pax8_subscriptions row (tombstoned/unsynced) are still surfaced as historical breakdown rows with null billingTerm/status (Pitfall 4)"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Pattern: whitelist map for sort column resolution (SORT_COLUMNS) — reusable template for any future paginated/sortable list route in this codebase"
|
||||||
|
|
||||||
|
requirements-completed: [PAX8-13]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 12min
|
||||||
|
completed: 2026-07-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 14 Plan 01: PAX8 Companies API Routes Summary
|
||||||
|
|
||||||
|
**Two read-only API routes backing the /pax8 Companies tab: an injection-safe paginated/sortable/searchable company list, and a per-company drill-down that correctly windows the "latest billed amount" per subscription instead of using a single global cutoff date.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 12 min
|
||||||
|
- **Started:** 2026-07-11T18:16:00Z
|
||||||
|
- **Completed:** 2026-07-11T18:28:23Z
|
||||||
|
- **Tasks:** 2 completed
|
||||||
|
- **Files modified:** 2 (both new)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- `GET /api/pax8/companies` returns `{ items, total, limit, offset }` with camelCase fields, a hardcoded sort-column whitelist, and fully parameterized search/limit/offset — no SQL injection surface on any user-controllable input.
|
||||||
|
- `GET /api/pax8/companies/[id]` returns `{ company, subscriptions, costTotal }`, correctly handling PAX8's NCE per-subscription anniversary billing (each subscription's own latest order-item row, not a single company-wide `MAX(start_period)`), uses `line_total` (never `unit_price * quantity`), and surfaces historical order-item rows for subscriptions that no longer exist in `pax8_subscriptions`.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: GET /api/pax8/companies — paginated company list** - `443b6ce` (feat)
|
||||||
|
2. **Task 2: GET /api/pax8/companies/[id] — subscriptions + cost breakdown** - `2cc1abb` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (this commit, following SUMMARY.md creation)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `app/api/pax8/companies/route.ts` — paginated/sortable/searchable PAX8 company list, requireAuth-gated
|
||||||
|
- `app/api/pax8/companies/[id]/route.ts` — single-company subscriptions + per-subscription latest-billed cost breakdown
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Both routes use `requireAuth()` only, never `requirePermission`, per CONTEXT.md D-07 — this is read-only view data for any authenticated manager, not an admin-gated action.
|
||||||
|
- `match_confidence` and `autotask_company_id` are cast to `::text` in SQL and coerced with `Number()` in JS to avoid `pg`'s default numeric/bigint string-return behavior producing unexpected types downstream.
|
||||||
|
- Tombstoned-subscription order-item rows (3 of 436 distinct subscription_ids referenced by `pax8_order_items` live-verified in RESEARCH.md to have no matching `pax8_subscriptions` row) are appended as extra breakdown rows with `billingTerm`/`status` set to `null`, per the plan's explicit instruction — these represent real historical spend that would otherwise silently vanish from `costTotal`.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written. One in-flight self-correction during Task 1 implementation (documented below) was caught and fixed before committing, not a deviation from the plan's design.
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed parameter-index mismatch in the companies list total-count query**
|
||||||
|
- **Found during:** Task 1 (GET /api/pax8/companies)
|
||||||
|
- **Issue:** The total-count query reused the `searchFilter` SQL fragment built for the main list query (where `search` binds to `$3` because `$1`/`$2` are `limit`/`offset`), but passed only `[search]` as its params array — a mismatch that would bind the search term to `$1` while the SQL referenced `$3`, causing either a runtime bind error or (worse) a silently wrong/unfiltered count.
|
||||||
|
- **Fix:** Built a separate `totalParams`/`totalSearchFilter` pair scoped to the total-count query's own parameter numbering (search binds to `$1` there, since that query has no limit/offset params).
|
||||||
|
- **Files modified:** app/api/pax8/companies/route.ts
|
||||||
|
- **Verification:** `npx tsc --noEmit --pretty` passes; manual trace of parameter binding for both the with-search and without-search cases confirms correct `$N` alignment.
|
||||||
|
- **Committed in:** 443b6ce (Task 1 commit — fixed before first commit, not a follow-up)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (1 bug, caught pre-commit during implementation)
|
||||||
|
**Impact on plan:** No scope creep — this was an implementation-time bug caught and fixed before the task was ever committed, not a change to the plan's design.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Both routes are ready for Plan 04 (the `/pax8` page) to consume via `fetch()`.
|
||||||
|
- Response shapes match the plan's `must_haves.artifacts` contract exactly: list route exports `GET` returning `{ items, total, limit, offset }`; drill-down route exports `GET` returning `{ company, subscriptions, costTotal }`.
|
||||||
|
- No blockers for downstream plans in this wave (14-02, 14-03) — no file overlap, no shared state introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 14-pax8-ui-surface*
|
||||||
|
*Completed: 2026-07-11*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: app/api/pax8/companies/route.ts
|
||||||
|
- FOUND: app/api/pax8/companies/[id]/route.ts
|
||||||
|
- FOUND: .planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md
|
||||||
|
- FOUND commit: 443b6ce
|
||||||
|
- FOUND commit: 2cc1abb
|
||||||
|
- FOUND commit: 501c7e4
|
||||||
213
app/api/pax8/companies/[id]/route.ts
Normal file
213
app/api/pax8/companies/[id]/route.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/**
|
||||||
|
* GET /api/pax8/companies/[id]
|
||||||
|
* Returns a single PAX8 company's current subscriptions joined to each
|
||||||
|
* subscription's latest actually-billed order-item line (windowed per
|
||||||
|
* subscription_id, per D-03 — see 14-RESEARCH.md Pitfall 2), plus a
|
||||||
|
* summed cost total.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth } from '@/lib/auth-utils';
|
||||||
|
import postgresClient from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f-]{36}$/i;
|
||||||
|
|
||||||
|
interface CompanyHeaderRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state_or_province: string | null;
|
||||||
|
country: string | null;
|
||||||
|
website: string | null;
|
||||||
|
autotask_company_id: string | null;
|
||||||
|
match_confidence: string | null;
|
||||||
|
match_method: string | null;
|
||||||
|
synced_at: string | null;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionRow {
|
||||||
|
subscription_id: string;
|
||||||
|
product_id: string | null;
|
||||||
|
product_name: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
quantity: number;
|
||||||
|
billing_term: string | null;
|
||||||
|
status: string | null;
|
||||||
|
price: string | null;
|
||||||
|
partner_cost: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItemRow {
|
||||||
|
subscription_id: string;
|
||||||
|
product_id: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
description: string | null;
|
||||||
|
item_type: string | null;
|
||||||
|
start_period: string | null;
|
||||||
|
end_period: string | null;
|
||||||
|
quantity: number | null;
|
||||||
|
unit_price: string | null;
|
||||||
|
line_total: string | null;
|
||||||
|
partner_cost: string | null;
|
||||||
|
partner_cost_total: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionBreakdownItem {
|
||||||
|
subscriptionId: string;
|
||||||
|
productName: string;
|
||||||
|
sku: string | null;
|
||||||
|
quantity: number | null;
|
||||||
|
billingTerm: string | null;
|
||||||
|
status: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
latestBilledAmount: number;
|
||||||
|
startPeriod: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { error } = await requireAuth();
|
||||||
|
if (error) return error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!UUID_RE.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid company id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyRes = await postgresClient.query<CompanyHeaderRow>(
|
||||||
|
`SELECT id, name, status, city, state_or_province, country, website,
|
||||||
|
autotask_company_id::text AS autotask_company_id,
|
||||||
|
match_confidence::text AS match_confidence, match_method,
|
||||||
|
synced_at::text AS synced_at, is_deleted
|
||||||
|
FROM pax8_companies
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const companyRow = companyRes.rows[0];
|
||||||
|
if (!companyRow) {
|
||||||
|
return NextResponse.json({ error: 'Company not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedCompanyName: string | null = null;
|
||||||
|
if (companyRow.autotask_company_id) {
|
||||||
|
const matchedRes = await postgresClient.query<{ company_name: string }>(
|
||||||
|
`SELECT company_name FROM companies WHERE id = $1`,
|
||||||
|
[companyRow.autotask_company_id]
|
||||||
|
);
|
||||||
|
matchedCompanyName = matchedRes.rows[0]?.company_name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: current subscriptions for this company.
|
||||||
|
const subscriptionsRes = await postgresClient.query<SubscriptionRow>(
|
||||||
|
`SELECT s.id AS subscription_id, s.product_id, p.name AS product_name, p.sku,
|
||||||
|
s.quantity, s.billing_term, s.status, s.price::text AS price,
|
||||||
|
s.partner_cost::text AS partner_cost, s.currency
|
||||||
|
FROM pax8_subscriptions s
|
||||||
|
LEFT JOIN pax8_products p ON p.id = s.product_id
|
||||||
|
WHERE s.pax8_company_id = $1 AND s.is_deleted = false
|
||||||
|
ORDER BY p.name NULLS LAST`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: latest actually-billed order-item line PER subscription
|
||||||
|
// (windowed, not a global MAX — see 14-RESEARCH.md Pitfall 2).
|
||||||
|
const orderItemsRes = await postgresClient.query<OrderItemRow>(
|
||||||
|
`SELECT DISTINCT ON (subscription_id)
|
||||||
|
subscription_id, product_id, sku, description, item_type,
|
||||||
|
start_period::text AS start_period, end_period::text AS end_period,
|
||||||
|
quantity, unit_price::text AS unit_price, line_total::text AS line_total,
|
||||||
|
partner_cost::text AS partner_cost, partner_cost_total::text AS partner_cost_total
|
||||||
|
FROM pax8_order_items
|
||||||
|
WHERE pax8_company_id = $1 AND is_deleted = false
|
||||||
|
AND subscription_id IS NOT NULL
|
||||||
|
ORDER BY subscription_id, start_period DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestOrderItemBySubscription = new Map<string, OrderItemRow>();
|
||||||
|
for (const row of orderItemsRes.rows) {
|
||||||
|
latestOrderItemBySubscription.set(row.subscription_id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions: SubscriptionBreakdownItem[] = [];
|
||||||
|
|
||||||
|
for (const sub of subscriptionsRes.rows) {
|
||||||
|
const orderItem = latestOrderItemBySubscription.get(sub.subscription_id);
|
||||||
|
const price = sub.price !== null ? Number(sub.price) : 0;
|
||||||
|
const quantity = sub.quantity ?? null;
|
||||||
|
// Pitfall 3: never recompute from price*quantity when a line_total exists.
|
||||||
|
const latestBilledAmount =
|
||||||
|
orderItem?.line_total !== null && orderItem?.line_total !== undefined
|
||||||
|
? Number(orderItem.line_total)
|
||||||
|
: price * (quantity ?? 0);
|
||||||
|
// Pitfall 4: fallback label chain.
|
||||||
|
const productName =
|
||||||
|
sub.product_name || orderItem?.description || sub.sku || orderItem?.sku || 'Unknown item';
|
||||||
|
|
||||||
|
subscriptions.push({
|
||||||
|
subscriptionId: sub.subscription_id,
|
||||||
|
productName,
|
||||||
|
sku: sub.sku ?? orderItem?.sku ?? null,
|
||||||
|
quantity,
|
||||||
|
billingTerm: sub.billing_term,
|
||||||
|
status: sub.status,
|
||||||
|
currency: sub.currency,
|
||||||
|
latestBilledAmount,
|
||||||
|
startPeriod: orderItem?.start_period ?? null,
|
||||||
|
});
|
||||||
|
// Consume this subscription's order item so it isn't re-appended below.
|
||||||
|
if (orderItem) latestOrderItemBySubscription.delete(sub.subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining Step-2 rows belong to tombstoned/unsynced subscriptions
|
||||||
|
// (Pitfall 4) — still surface them as historical breakdown rows.
|
||||||
|
for (const orderItem of latestOrderItemBySubscription.values()) {
|
||||||
|
const lineTotal = orderItem.line_total !== null ? Number(orderItem.line_total) : 0;
|
||||||
|
subscriptions.push({
|
||||||
|
subscriptionId: orderItem.subscription_id,
|
||||||
|
productName: orderItem.description || orderItem.sku || 'Unknown item',
|
||||||
|
sku: orderItem.sku,
|
||||||
|
quantity: orderItem.quantity,
|
||||||
|
billingTerm: null,
|
||||||
|
status: null,
|
||||||
|
currency: null,
|
||||||
|
latestBilledAmount: lineTotal,
|
||||||
|
startPeriod: orderItem.start_period,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const costTotal = subscriptions.reduce((sum, s) => sum + s.latestBilledAmount, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
company: {
|
||||||
|
id: companyRow.id,
|
||||||
|
name: companyRow.name,
|
||||||
|
status: companyRow.status,
|
||||||
|
city: companyRow.city,
|
||||||
|
stateOrProvince: companyRow.state_or_province,
|
||||||
|
country: companyRow.country,
|
||||||
|
website: companyRow.website,
|
||||||
|
autotaskCompanyId: companyRow.autotask_company_id !== null ? Number(companyRow.autotask_company_id) : null,
|
||||||
|
matchConfidence: companyRow.match_confidence !== null ? Number(companyRow.match_confidence) : null,
|
||||||
|
matchMethod: companyRow.match_method,
|
||||||
|
matchedCompanyName,
|
||||||
|
syncedAt: companyRow.synced_at,
|
||||||
|
isDeleted: companyRow.is_deleted,
|
||||||
|
},
|
||||||
|
subscriptions,
|
||||||
|
costTotal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch PAX8 company detail:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Failed to fetch PAX8 company detail' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/api/pax8/companies/route.ts
Normal file
117
app/api/pax8/companies/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue