wulf-pulse/app/api/admin/company-scope/route.ts
lorentz db375fb0e6 feat(admin): client scope — filter analytics to recurring-revenue companies
Adds company-level opt-out scoping so white-label / subcontract clients
(TTG, LEC, PER, VCF, Trivium Packaging, TNT Pizza, etc.) can be excluded
from Wulf's own dashboard KPIs and ticket analytics without affecting
per-company drill-down views.

- migration 082: company_scope table (opt-out; absent row = in scope)
- GET/PATCH /api/admin/company-scope[/companyId] — list + upsert
- /admin/client-scope — searchable company list with Switch per row,
  type filter, and in/out scope filter; excluded rows are dimmed
- dashboard overview KPIs now exclude out-of-scope company tickets
- analyzer /tickets query excludes out-of-scope when no specific
  client is selected (explicit per-company selection still works)
- "Client Scope" tile added to admin Tools & Data section

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:40:47 -04:00

75 lines
2.2 KiB
TypeScript

/**
* GET /api/admin/company-scope
* Returns all active companies with their current in_scope status.
* Companies without a company_scope row are implicitly in scope (true).
*
* Query params:
* search — filter by company name (ILIKE)
* type — filter by company_type integer
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
interface CompanyRow {
id: string;
company_name: string;
company_type: number | null;
in_scope: boolean;
}
const COMPANY_TYPE_LABELS: Record<number, string> = {
1: 'Customer',
2: 'Canceled',
3: 'Cold',
4: 'Dead',
5: 'Warm',
6: 'Vendor',
7: 'Partner',
8: 'Prospect',
};
export async function GET(request: NextRequest) {
const { error } = await requireAdmin();
if (error) return error;
const url = request.nextUrl;
const search = url.searchParams.get('search')?.trim() || null;
const typeParam = url.searchParams.get('type');
const typeFilter = typeParam ? parseInt(typeParam, 10) : null;
const params: unknown[] = [];
const conditions = ['c.is_active = true', 'c.is_deleted = false'];
if (search) {
params.push(`%${search}%`);
conditions.push(`c.company_name ILIKE $${params.length}`);
}
if (typeFilter !== null && !isNaN(typeFilter)) {
params.push(typeFilter);
conditions.push(`c.company_type = $${params.length}`);
}
const result = await postgresClient.query<CompanyRow>(
`SELECT c.id::text, c.company_name, c.company_type,
COALESCE(cs.in_scope, true) AS in_scope
FROM companies c
LEFT JOIN company_scope cs ON cs.company_id = c.id
WHERE ${conditions.join(' AND ')}
ORDER BY c.company_name`,
params
);
const companies = result.rows.map((r) => ({
id: r.id,
companyName: r.company_name,
companyType: r.company_type,
companyTypeLabel: r.company_type != null ? (COMPANY_TYPE_LABELS[r.company_type] ?? `Type ${r.company_type}`) : null,
inScope: r.in_scope,
}));
const excluded = companies.filter((c) => !c.inScope).length;
return NextResponse.json({ companies, total: companies.length, excluded });
}