DOCS / SUPERPOWERS / PLANS / 2026 05 31 ENRICHMENT PHASE3B API COMPLETION
VIEW RAW

Enrichment Engine, Phase 3b: API Completion, Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Complete the /v1/companies surface: fetch one record, refresh one (charging a lookup), and a dedicated full-library export. Extract the CSV/row-mapping into a shared, unit-tested helper (DRY with the existing list route).

Architecture: Three thin route handlers reusing the Phase 3a plumbing (resolveBearer, ApiError, requireLookupQuota/chargeLookup, enrichCompany). A pure library-export.ts holds the flatten + CSV logic (including the formula-injection guard) so the list route, the export route, and tests share one implementation.

Tech Stack: Next.js 16 route handlers (nodejs runtime), Vitest.


Context the engineer needs

  • Branch: continue on feat/enrichment-engine.
  • Phase 3a exists: apps/web/app/api/v1/companies/route.ts has POST (enrich into library) + GET (list, format json|csv). Its GET currently inlines a row-flatten + CSV-escape (with a formula-injection guard prefixing cells starting with =+-@\t\r). This task extracts that into a shared helper and reuses it.
  • Plumbing: @/lib/api/auth (resolveBearer, bearerFromHeaders, clientIp), @/lib/api/errors (ApiError, apiError), @/lib/api/plan-gate (requireLookupQuota, chargeLookup), @/lib/supabase/admin (createSupabaseAdminClient), @/lib/enrichment/service (enrichCompany), @agent-metrics/core (normalizeDomain), @agent-metrics/types (EnrichedCompany).
  • Dynamic route convention (Next 16): export async function GET(request: NextRequest, context: { params: Promise<{ domain: string }> }) { const { domain } = await context.params; ... }. Set export const runtime = "nodejs";.
  • Error codes: existing set has no 404; this task adds not_found (404).
  • Tables: org_companies(org_id, domain, research_id, external_id, tags, added_at, last_refreshed_at), company_research(id, domain unique, company_data jsonb).
  • PostgREST embed company_research(company_data) can come back as an object or a single-element array; handle both.
  • Static vs dynamic segment: app/api/v1/companies/export/route.ts (static) and app/api/v1/companies/[domain]/route.ts (dynamic) coexist; Next resolves the static export segment first, and real domains always contain a dot so they fall to [domain].

File structure

  • Modify: apps/web/lib/api/errors.ts — add not_found (404).
  • Create: apps/web/lib/enrichment/library-export.ts (+ test) — mapLibraryRow, LIBRARY_CSV_COLS, toCsv.
  • Modify: apps/web/app/api/v1/companies/route.ts — reuse the helper in GET.
  • Create: apps/web/app/api/v1/companies/[domain]/route.ts — GET one.
  • Create: apps/web/app/api/v1/companies/[domain]/refresh/route.ts — POST refresh.
  • Create: apps/web/app/api/v1/companies/export/route.ts — GET full export.

Task 1: Shared export helper + not_found code + refactor list route

Files: Modify apps/web/lib/api/errors.ts; create apps/web/lib/enrichment/library-export.ts + test; modify apps/web/app/api/v1/companies/route.ts.

  • Step 1: Add the error code. In apps/web/lib/api/errors.ts add | "not_found" to the ApiErrorCode union and not_found: 404, to the STATUS map.

  • Step 2: Write the failing test apps/web/lib/enrichment/library-export.test.ts:

import { describe, expect, it } from "vitest";
 
import { mapLibraryRow, toCsv } from "./library-export";
 
const rawRow = {
  domain: "acme.nl",
  external_id: "crm-1",
  tags: ["nl", "saas"],
  added_at: "2026-05-31T10:00:00Z",
  last_refreshed_at: "2026-05-31T10:00:00Z",
  company_research: {
    company_data: {
      domain: "acme.nl",
      company_name: "Acme BV",
      country: "NL",
      industry: "saas",
      headcount_estimate: 40,
      roles: [],
      wages: [],
      business_case: { annual_labour_cost_eur: 1000, potential_saving_eur: 200 },
      confidence: { overall: 0.7 },
      sources: [],
    },
  },
};
 
describe("mapLibraryRow", () => {
  it("flattens the embedded research into a row", () => {
    const r = mapLibraryRow(rawRow);
    expect(r.company_name).toBe("Acme BV");
    expect(r.annual_labour_cost_eur).toBe(1000);
    expect(r.confidence).toBe(0.7);
  });
  it("handles the embed coming back as an array", () => {
    const r = mapLibraryRow({ ...rawRow, company_research: [rawRow.company_research] });
    expect(r.country).toBe("NL");
  });
});
 
describe("toCsv", () => {
  it("emits a header and one row, joining tags with a pipe", () => {
    const csv = toCsv([mapLibraryRow(rawRow)]);
    const lines = csv.split("\n");
    expect(lines[0]).toContain("domain,external_id,company_name");
    expect(lines[1]).toContain("acme.nl");
    expect(lines[1]).toContain("nl|saas");
  });
  it("neutralises CSV formula injection", () => {
    const evil = mapLibraryRow({
      ...rawRow,
      company_research: {
        company_data: { ...rawRow.company_research.company_data, company_name: "=cmd()" },
      },
    });
    const csv = toCsv([evil]);
    expect(csv).toContain("'=cmd()");
  });
});
  • Step 3: Run, verify FAIL. pnpm exec vitest run apps/web/lib/enrichment/library-export.test.ts

  • Step 4: Implement apps/web/lib/enrichment/library-export.ts:

import { type EnrichedCompany } from "@agent-metrics/types";
 
export interface LibraryRowRaw {
  domain: string;
  external_id: string | null;
  tags: string[];
  added_at: string;
  last_refreshed_at: string;
  company_research: { company_data: EnrichedCompany } | { company_data: EnrichedCompany }[] | null;
}
 
export interface LibraryRow {
  domain: string;
  external_id: string | null;
  company_name: string | null;
  country: string | null;
  industry: string | null;
  headcount_estimate: number | null;
  annual_labour_cost_eur: number | null;
  potential_saving_eur: number | null;
  confidence: number | null;
  tags: string[];
  added_at: string;
  last_refreshed_at: string;
}
 
export const LIBRARY_CSV_COLS: (keyof LibraryRow)[] = [
  "domain",
  "external_id",
  "company_name",
  "country",
  "industry",
  "headcount_estimate",
  "annual_labour_cost_eur",
  "potential_saving_eur",
  "confidence",
  "tags",
  "added_at",
  "last_refreshed_at",
];
 
export function mapLibraryRow(r: LibraryRowRaw): LibraryRow {
  const embed = Array.isArray(r.company_research) ? r.company_research[0] : r.company_research;
  const c = embed?.company_data;
  return {
    domain: r.domain,
    external_id: r.external_id,
    company_name: c?.company_name ?? null,
    country: c?.country ?? null,
    industry: c?.industry ?? null,
    headcount_estimate: c?.headcount_estimate ?? null,
    annual_labour_cost_eur: c?.business_case?.annual_labour_cost_eur ?? null,
    potential_saving_eur: c?.business_case?.potential_saving_eur ?? null,
    confidence: c?.confidence?.overall ?? null,
    tags: r.tags ?? [],
    added_at: r.added_at,
    last_refreshed_at: r.last_refreshed_at,
  };
}
 
// CSV with a formula-injection guard: cells starting with = + - @ tab or CR are
// prefixed with an apostrophe so Excel/Sheets will not execute them.
function esc(v: unknown): string {
  let s = v === null || v === undefined ? "" : Array.isArray(v) ? v.join("|") : String(v);
  if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
 
export function toCsv(rows: LibraryRow[]): string {
  const lines = [LIBRARY_CSV_COLS.join(",")];
  for (const r of rows) {
    lines.push(LIBRARY_CSV_COLS.map((c) => esc(r[c])).join(","));
  }
  return lines.join("\n");
}
  • Step 5: Run, verify PASS.

  • Step 6: Refactor the list route to reuse the helper. In apps/web/app/api/v1/companies/route.ts:

    • Add to the imports: import { mapLibraryRow, toCsv } from "@/lib/enrichment/library-export";
    • In GET, replace the inline .map((r) => { ... }) row-building with .map(mapLibraryRow) (the select string already matches LibraryRowRaw), and replace the entire inline CSV block (the cols, esc, lines construction) with const body = toCsv(rows); returned in the NextResponse. Remove the now-unused inline cols/esc. Keep the response headers (content-type text/csv, content-disposition attachment filename="companies.csv"). Do not change POST.
  • Step 7: Typecheck + full suite. pnpm --filter @agent-metrics/web exec tsc --noEmit, then pnpm exec vitest run (all green, no regressions).

  • Step 8: Commit.

git add apps/web/lib/api/errors.ts apps/web/lib/enrichment/library-export.ts apps/web/lib/enrichment/library-export.test.ts apps/web/app/api/v1/companies/route.ts
git commit -m "refactor(api): shared library-export helper + not_found code"

Body line: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>.


Task 2: GET /v1/companies/{domain} + POST /v1/companies/{domain}/refresh

Files: Create apps/web/app/api/v1/companies/[domain]/route.ts and apps/web/app/api/v1/companies/[domain]/refresh/route.ts.

  • Step 1: Create apps/web/app/api/v1/companies/[domain]/route.ts:
import { NextResponse, type NextRequest } from "next/server";
 
import { normalizeDomain } from "@agent-metrics/core";
import { type EnrichedCompany } from "@agent-metrics/types";
 
import { ApiError, apiError } from "@/lib/api/errors";
import { bearerFromHeaders, clientIp, resolveBearer } from "@/lib/api/auth";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
 
export const runtime = "nodejs";
 
// GET /v1/companies/{domain} -> one enriched record from the caller's library.
export async function GET(request: NextRequest, context: { params: Promise<{ domain: string }> }) {
  try {
    const cached = await resolveBearer({
      bearer: bearerFromHeaders(request.headers),
      ip: clientIp(request.headers),
    });
    const { domain: raw } = await context.params;
    const domain = normalizeDomain(decodeURIComponent(raw));
    if (!domain) throw new ApiError("validation_error", "Invalid domain.", { field: "domain" });
 
    const admin = createSupabaseAdminClient();
    const { data } = await admin
      .from("org_companies")
      .select(
        "domain, external_id, tags, added_at, last_refreshed_at, company_research(company_data)",
      )
      .eq("org_id", cached.org_id)
      .eq("domain", domain)
      .maybeSingle();
    if (!data) throw new ApiError("not_found", "This company is not in your library.");
 
    const row = data as {
      domain: string;
      external_id: string | null;
      tags: string[];
      added_at: string;
      last_refreshed_at: string;
      company_research:
        | { company_data: EnrichedCompany }
        | { company_data: EnrichedCompany }[]
        | null;
    };
    const embed = Array.isArray(row.company_research)
      ? row.company_research[0]
      : row.company_research;
    return NextResponse.json({
      domain: row.domain,
      external_id: row.external_id,
      tags: row.tags,
      added_at: row.added_at,
      last_refreshed_at: row.last_refreshed_at,
      company: embed?.company_data ?? null,
    });
  } catch (err) {
    if (err instanceof ApiError) return err.toResponse();
    // eslint-disable-next-line no-console
    console.error("[/v1/companies/{domain} GET] internal", err);
    return apiError("internal", "Unexpected error.");
  }
}
  • Step 2: Create apps/web/app/api/v1/companies/[domain]/refresh/route.ts:
import { NextResponse, type NextRequest } from "next/server";
 
import { normalizeDomain } from "@agent-metrics/core";
 
import { ApiError, apiError } from "@/lib/api/errors";
import { bearerFromHeaders, clientIp, resolveBearer } from "@/lib/api/auth";
import { chargeLookup, requireLookupQuota } from "@/lib/api/plan-gate";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { enrichCompany } from "@/lib/enrichment/service";
 
export const runtime = "nodejs";
 
// POST /v1/companies/{domain}/refresh -> re-enriches an owned company. Charges
// one lookup. 404 if the company is not in the caller's library.
export async function POST(request: NextRequest, context: { params: Promise<{ domain: string }> }) {
  try {
    const cached = await resolveBearer({
      bearer: bearerFromHeaders(request.headers),
      ip: clientIp(request.headers),
    });
    const { domain: raw } = await context.params;
    const domain = normalizeDomain(decodeURIComponent(raw));
    if (!domain) throw new ApiError("validation_error", "Invalid domain.", { field: "domain" });
 
    const admin = createSupabaseAdminClient();
    const { data: existing } = await admin
      .from("org_companies")
      .select("id")
      .eq("org_id", cached.org_id)
      .eq("domain", domain)
      .maybeSingle();
    if (!existing) throw new ApiError("not_found", "This company is not in your library.");
 
    await requireLookupQuota(cached.org_id);
    const { record } = await enrichCompany(domain, { forceRefresh: true });
 
    const { data: research } = await admin
      .from("company_research")
      .select("id")
      .eq("domain", domain)
      .maybeSingle();
    await admin
      .from("org_companies")
      .update({
        research_id: (research as { id: string } | null)?.id ?? null,
        last_refreshed_at: new Date().toISOString(),
      })
      .eq("org_id", cached.org_id)
      .eq("domain", domain);
    await chargeLookup(cached.org_id, domain, "refresh");
 
    return NextResponse.json({ company: record, charged: true });
  } catch (err) {
    if (err instanceof ApiError) return err.toResponse();
    // eslint-disable-next-line no-console
    console.error("[/v1/companies/{domain}/refresh POST] internal", err);
    return apiError("internal", "Unexpected error.");
  }
}
  • Step 3: Typecheck. pnpm --filter @agent-metrics/web exec tsc --noEmit. Fix only these two files if needed.

  • Step 4: Commit.

git add "apps/web/app/api/v1/companies/[domain]/route.ts" "apps/web/app/api/v1/companies/[domain]/refresh/route.ts"
git commit -m "feat(api): GET /v1/companies/{domain} + POST refresh"

Body line as above.


Task 3: GET /v1/companies/export (full library)

Files: Create apps/web/app/api/v1/companies/export/route.ts.

  • Step 1: Create apps/web/app/api/v1/companies/export/route.ts:
import { NextResponse, type NextRequest } from "next/server";
 
import { ApiError, apiError } from "@/lib/api/errors";
import { bearerFromHeaders, clientIp, resolveBearer } from "@/lib/api/auth";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { type LibraryRowRaw, mapLibraryRow, toCsv } from "@/lib/enrichment/library-export";
 
export const runtime = "nodejs";
 
const PAGE = 1000;
 
// GET /v1/companies/export?format=csv|json&tag=...
// Full library as a downloadable file (pages through every row). Free.
export async function GET(request: NextRequest) {
  try {
    const cached = await resolveBearer({
      bearer: bearerFromHeaders(request.headers),
      ip: clientIp(request.headers),
    });
    const sp = request.nextUrl.searchParams;
    const format = sp.get("format") === "json" ? "json" : "csv";
    const tag = sp.get("tag");
 
    const admin = createSupabaseAdminClient();
    const raw: LibraryRowRaw[] = [];
    for (let from = 0; ; from += PAGE) {
      let q = admin
        .from("org_companies")
        .select(
          "domain, external_id, tags, added_at, last_refreshed_at, company_research(company_data)",
        )
        .eq("org_id", cached.org_id)
        .order("added_at", { ascending: false })
        .range(from, from + PAGE - 1);
      if (tag) q = q.contains("tags", [tag]);
      const { data, error } = await q;
      if (error) throw new ApiError("internal", "Could not export companies.");
      const batch = (data ?? []) as unknown as LibraryRowRaw[];
      raw.push(...batch);
      if (batch.length < PAGE) break;
    }
 
    const rows = raw.map(mapLibraryRow);
 
    if (format === "json") {
      return new NextResponse(JSON.stringify({ companies: rows }, null, 2), {
        status: 200,
        headers: {
          "content-type": "application/json; charset=utf-8",
          "content-disposition": 'attachment; filename="companies.json"',
        },
      });
    }
    return new NextResponse(toCsv(rows), {
      status: 200,
      headers: {
        "content-type": "text/csv; charset=utf-8",
        "content-disposition": 'attachment; filename="companies.csv"',
      },
    });
  } catch (err) {
    if (err instanceof ApiError) return err.toResponse();
    // eslint-disable-next-line no-console
    console.error("[/v1/companies/export GET] internal", err);
    return apiError("internal", "Unexpected error.");
  }
}
  • Step 2: Typecheck + full suite. pnpm --filter @agent-metrics/web exec tsc --noEmit and pnpm exec vitest run (green).

  • Step 3: Commit.

git add apps/web/app/api/v1/companies/export/route.ts
git commit -m "feat(api): GET /v1/companies/export (full library, csv or json)"

Body line as above.


Self-review (plan author)

  • Spec coverage: GET one (404 via new not_found), refresh (charges a lookup, 404 if unowned), full export (paged, csv/json, free). CSV logic is now shared + tested (incl. the formula-injection guard), removing the Phase 3a duplication.
  • No placeholders: all routes concrete; params uses the verified Next 16 Promise convention; PostgREST embed handled as object-or-array everywhere.
  • Type consistency: LibraryRowRaw is the single shape used by the list route, the export route, and the helper; mapLibraryRow/toCsv signatures match across tasks.

Out of scope / next

  • Phase 4 (bulk + worker), Phase 5 (pricing live + migration), Phase 6 (frontend library UI), Phase 7 (docs/SDK).
  • Known debt: service.ts stores loaded cost as wage_data.gross_hourly_eur (mislabel); ESLint config has a pre-existing circular-JSON crash.

Found a typo or want to suggest an edit? Email support@triadagency.ai.