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.tshas 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; ... }. Setexport 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) andapp/api/v1/companies/[domain]/route.ts(dynamic) coexist; Next resolves the staticexportsegment first, and real domains always contain a dot so they fall to[domain].
File structure
- Modify:
apps/web/lib/api/errors.ts— addnot_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.tsadd| "not_found"to theApiErrorCodeunion andnot_found: 404,to theSTATUSmap. -
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 matchesLibraryRowRaw), and replace the entire inline CSV block (thecols,esc,linesconstruction) withconst body = toCsv(rows);returned in theNextResponse. Remove the now-unused inlinecols/esc. Keep the response headers (content-type text/csv, content-disposition attachment filename="companies.csv"). Do not change POST.
- Add to the imports:
-
Step 7: Typecheck + full suite.
pnpm --filter @agent-metrics/web exec tsc --noEmit, thenpnpm 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 --noEmitandpnpm 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;
paramsuses the verified Next 16 Promise convention; PostgREST embed handled as object-or-array everywhere. - Type consistency:
LibraryRowRawis the single shape used by the list route, the export route, and the helper;mapLibraryRow/toCsvsignatures 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.tsstores loaded cost aswage_data.gross_hourly_eur(mislabel); ESLint config has a pre-existing circular-JSON crash.