DOCS / SUPERPOWERS / PLANS / 2026 05 31 ENRICHMENT PHASE3A COMPANIES API
VIEW RAW

Enrichment Engine, Phase 3a: Companies API + Lookups + Hard Cap, Implementation Plan

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

Goal: Expose the enrichment engine as a product API: enrich a domain into the caller's per-org library (charging one lookup, hard-capped per plan with an upgrade error, never overage-billed), and list/export the library as JSON or CSV for outreach.

Architecture: New /v1/companies routes follow the existing /v1 pattern (resolveBearer auth, ApiError envelope, service-role Supabase). Lookup quota + charging live in plan-gate.ts against the company_lookups ledger. A pure normalizeDomain + billingPeriod are unit-tested; the routes are thin.

Tech Stack: Next.js route handlers (nodejs runtime), @/lib/api/auth, @/lib/api/errors, @/lib/api/plan-gate, the enrichment service, Supabase service-role client, Vitest.


Context the engineer needs

  • Branch: continue on feat/enrichment-engine.
  • Auth pattern (apps/web/lib/api/auth.ts): resolveBearer({ bearer, ip }) -> { org_id, api_key_id, scopes }; helpers bearerFromHeaders(headers), clientIp(headers). Throws ApiError.
  • Error envelope (apps/web/lib/api/errors.ts): ApiError(code, message, { hint?, field?, retry_after_seconds? }) and apiError(code, message, extras). Codes have fixed HTTP statuses in a STATUS map. Route shape: try { ... } catch (err) { if (err instanceof ApiError) return err.toResponse(); console.error(...); return apiError("internal", "Unexpected error."); }.
  • Plan gate (apps/web/lib/api/plan-gate.ts): PlanKey = "free" | "pro" | "agency" | "enterprise" (this is authoritative; the OrgPlanSchema in @agent-metrics/types is a stale outlier and is NOT used for gating). PLAN_FEATURES: Record<PlanKey, PlanFeatures>, getOrgPlan(orgId), getPlanFeatures(plan). Agency pools across the tree via organizations.parent_org_id.
  • Enrichment service (apps/web/lib/enrichment/service.ts): enrichCompany(domain, { forceRefresh }) -> { record, fromCache }; writes/reads the global company_research cache. record is an EnrichedCompany.
  • Tables: org_companies (org_id, domain, research_id, external_id, tags, added_at, last_refreshed_at, unique(org_id,domain)), company_research (id, domain unique, company_data jsonb, ...), company_lookups (org_id, domain, kind 'new'|'refresh', billing_period 'YYYY-MM', created_at).
  • Lookup economics (Ralf): charge one lookup when a domain is NEW to the org's library or a user-requested refresh; viewing/listing/exporting existing entries is free. Hard monthly cap per plan, NO overage: at the cap, enrich/refresh returns an upgrade error. Caps: Free 10 (lifetime, one-time), Pro 100/mo, Agency 500/mo pooled across the tree, Enterprise unlimited.
  • Domain runtime: routes that read the Authorization header are auto-dynamic; set export const runtime = "nodejs"; (argon2 + service role). Never use force-dynamic.

File structure

  • Create: packages/core/src/enrichment/domain.ts (+ test) — pure normalizeDomain.
  • Modify: packages/core/src/index.ts — export it.
  • Modify: apps/web/lib/api/errors.ts — add lookup_quota_exceeded (402).
  • Modify: apps/web/lib/api/plan-gate.tsmonthly_lookups, billingPeriod, countLookups, requireLookupQuota, chargeLookup.
  • Create: apps/web/lib/api/plan-gate.test.ts — pure tests for billingPeriod + cap values.
  • Create: apps/web/app/api/v1/companies/route.ts — POST (enrich) + GET (list, json/csv).

Task 1: Pure helpers + lookup quota/charging plumbing

Files: Create packages/core/src/enrichment/domain.ts (+ test); modify packages/core/src/index.ts, apps/web/lib/api/errors.ts, apps/web/lib/api/plan-gate.ts; create apps/web/lib/api/plan-gate.test.ts.

  • Step 1: Write the failing test packages/core/src/enrichment/domain.test.ts:
import { describe, expect, it } from "vitest";
 
import { normalizeDomain } from "./domain";
 
describe("normalizeDomain", () => {
  it("strips scheme, www, path, port and lowercases", () => {
    expect(normalizeDomain("https://www.Acme.NL/about?x=1")).toBe("acme.nl");
    expect(normalizeDomain("HTTP://Mollie.com:443/")).toBe("mollie.com");
    expect(normalizeDomain("  catawiki.com  ")).toBe("catawiki.com");
  });
  it("accepts a bare domain", () => {
    expect(normalizeDomain("picnic.app")).toBe("picnic.app");
  });
  it("returns null for junk", () => {
    expect(normalizeDomain("not a domain")).toBeNull();
    expect(normalizeDomain("")).toBeNull();
  });
});
  • Step 2: Run, verify FAIL. pnpm exec vitest run packages/core/src/enrichment/domain.test.ts

  • Step 3: Implement packages/core/src/enrichment/domain.ts:

// Normalises a user-supplied domain to a bare lowercase host. Returns null if
// it does not look like a domain. Pure.
export function normalizeDomain(raw: string): string | null {
  let s = raw.trim().toLowerCase();
  if (!s) return null;
  s = s.replace(/^[a-z]+:\/\//, ""); // scheme
  s = s.replace(/^www\./, "");
  s = s.split("/")[0] ?? s; // path
  s = s.split("?")[0] ?? s; // query
  s = s.split(":")[0] ?? s; // port
  if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(s)) {
    return null;
  }
  return s;
}
  • Step 4: Run, verify PASS. Then add to packages/core/src/index.ts:
export { normalizeDomain } from "./enrichment/domain";
  • Step 5: Add the error code. In apps/web/lib/api/errors.ts, add | "lookup_quota_exceeded" to the ApiErrorCode union, and lookup_quota_exceeded: 402, to the STATUS map.

  • Step 6: Add lookup plumbing to apps/web/lib/api/plan-gate.ts. (a) Add monthly_lookups: number | null; to the PlanFeatures type. (b) Add it to every entry in PLAN_FEATURES: free 10, pro 100, agency 500, enterprise null. (c) Append these exports at the end of the file:

// 'YYYY-MM' billing period for a date (UTC). Pure.
export function billingPeriod(d: Date): string {
  const y = d.getUTCFullYear();
  const m = String(d.getUTCMonth() + 1).padStart(2, "0");
  return `${y}-${m}`;
}
 
// The org ids whose lookups count against this org's quota. Agency pools the
// whole tree (anchor + children); everyone else is just themselves.
async function lookupScopeOrgIds(orgId: string, plan: PlanKey): Promise<string[]> {
  if (plan !== "agency") return [orgId];
  const admin = createSupabaseAdminClient();
  const { data: me } = await admin
    .from("organizations")
    .select("parent_org_id")
    .eq("id", orgId)
    .maybeSingle();
  const anchor = (me as { parent_org_id: string | null } | null)?.parent_org_id ?? orgId;
  const { data: kids } = await admin.from("organizations").select("id").eq("parent_org_id", anchor);
  const ids = new Set<string>([anchor, orgId]);
  for (const k of (kids ?? []) as { id: string }[]) ids.add(k.id);
  return [...ids];
}
 
// Lookups used: Free counts lifetime (its cap is one-time); paid plans count
// the current billing period.
async function countLookups(orgId: string, plan: PlanKey): Promise<number> {
  const admin = createSupabaseAdminClient();
  const ids = await lookupScopeOrgIds(orgId, plan);
  let q = admin
    .from("company_lookups")
    .select("id", { count: "exact", head: true })
    .in("org_id", ids);
  if (plan !== "free") q = q.eq("billing_period", billingPeriod(new Date()));
  const { count } = await q;
  return count ?? 0;
}
 
// Throws lookup_quota_exceeded when the org is at its hard cap. No overage.
export async function requireLookupQuota(orgId: string): Promise<void> {
  const plan = await getOrgPlan(orgId);
  const cap = PLAN_FEATURES[plan].monthly_lookups;
  if (cap === null) return;
  const used = await countLookups(orgId, plan);
  if (used >= cap) {
    throw new ApiError(
      "lookup_quota_exceeded",
      `You have reached your plan's research lookup limit (${cap}).`,
      { hint: "Upgrade your plan to raise the limit." },
    );
  }
}
 
// Records one consumed lookup in the ledger.
export async function chargeLookup(
  orgId: string,
  domain: string,
  kind: "new" | "refresh",
): Promise<void> {
  const admin = createSupabaseAdminClient();
  await admin
    .from("company_lookups")
    .insert({ org_id: orgId, domain, kind, billing_period: billingPeriod(new Date()) });
}
  • Step 7: Write apps/web/lib/api/plan-gate.test.ts (pure parts only):
import { describe, expect, it } from "vitest";
 
import { PLAN_FEATURES, billingPeriod } from "./plan-gate";
 
describe("billingPeriod", () => {
  it("formats YYYY-MM in UTC", () => {
    expect(billingPeriod(new Date("2026-05-31T23:30:00Z"))).toBe("2026-05");
    expect(billingPeriod(new Date("2026-01-01T00:00:00Z"))).toBe("2026-01");
  });
});
 
describe("lookup caps", () => {
  it("matches the agreed plan limits", () => {
    expect(PLAN_FEATURES.free.monthly_lookups).toBe(10);
    expect(PLAN_FEATURES.pro.monthly_lookups).toBe(100);
    expect(PLAN_FEATURES.agency.monthly_lookups).toBe(500);
    expect(PLAN_FEATURES.enterprise.monthly_lookups).toBeNull();
  });
});

Note: plan-gate.ts imports server-only, which vitest stubs, so this test imports fine.

  • Step 8: Run unit tests + typecheck. pnpm exec vitest run packages/core/src/enrichment/domain.test.ts apps/web/lib/api/plan-gate.test.ts (green), then pnpm --filter @agent-metrics/web exec tsc --noEmit and pnpm --filter @agent-metrics/core exec tsc --noEmit.

  • Step 9: Commit.

git add packages/core/src/enrichment/domain.ts packages/core/src/enrichment/domain.test.ts packages/core/src/index.ts apps/web/lib/api/errors.ts apps/web/lib/api/plan-gate.ts apps/web/lib/api/plan-gate.test.ts
git commit -m "feat(api): lookup quota + charging plumbing + domain normaliser"

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


Task 2: POST + GET /v1/companies

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

  • Step 1: Implement apps/web/app/api/v1/companies/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 { 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, external_id?, tags? }   ?refresh=true
// Enriches a domain into the caller's library. Charges one lookup when the
// company is new to the library or refresh=true; existing entries are free.
export async function POST(request: NextRequest) {
  try {
    const cached = await resolveBearer({
      bearer: bearerFromHeaders(request.headers),
      ip: clientIp(request.headers),
    });
    const body = (await request.json().catch(() => ({}))) as {
      domain?: unknown;
      external_id?: unknown;
      tags?: unknown;
    };
    const domain = normalizeDomain(typeof body.domain === "string" ? body.domain : "");
    if (!domain) {
      throw new ApiError("validation_error", "A valid domain is required.", { field: "domain" });
    }
    const refresh = request.nextUrl.searchParams.get("refresh") === "true";
    const externalId = typeof body.external_id === "string" ? body.external_id : null;
    const tags = Array.isArray(body.tags)
      ? body.tags.filter((t): t is string => typeof t === "string")
      : [];
 
    const admin = createSupabaseAdminClient();
    const { data: existing } = await admin
      .from("org_companies")
      .select("research_id")
      .eq("org_id", cached.org_id)
      .eq("domain", domain)
      .maybeSingle();
 
    // Free path: already owned and not a refresh -> return the stored research.
    if (existing && !refresh) {
      const { data: research } = await admin
        .from("company_research")
        .select("company_data")
        .eq("id", (existing as { research_id: string }).research_id)
        .maybeSingle();
      return NextResponse.json({
        company: (research as { company_data: EnrichedCompany } | null)?.company_data ?? null,
        charged: false,
      });
    }
 
    // Paid path: enforce the hard cap, then enrich + record ownership + charge.
    await requireLookupQuota(cached.org_id);
    const { record } = await enrichCompany(domain, { forceRefresh: refresh });
 
    const { data: research } = await admin
      .from("company_research")
      .select("id")
      .eq("domain", domain)
      .maybeSingle();
 
    await admin.from("org_companies").upsert(
      {
        org_id: cached.org_id,
        domain,
        research_id: (research as { id: string } | null)?.id ?? null,
        external_id: externalId,
        tags,
        last_refreshed_at: new Date().toISOString(),
      },
      { onConflict: "org_id,domain" },
    );
    await chargeLookup(cached.org_id, domain, existing ? "refresh" : "new");
 
    return NextResponse.json({ company: record, charged: true }, { status: existing ? 200 : 201 });
  } catch (err) {
    if (err instanceof ApiError) return err.toResponse();
    // eslint-disable-next-line no-console
    console.error("[/v1/companies POST] internal", err);
    return apiError("internal", "Unexpected error.");
  }
}
 
// GET /v1/companies?format=json|csv&tag=...&limit=...
// Lists the caller's library. Free. The outreach pull.
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") === "csv" ? "csv" : "json";
    const tag = sp.get("tag");
    const limit = Math.min(Number(sp.get("limit") ?? "500") || 500, 1000);
 
    const admin = createSupabaseAdminClient();
    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 })
      .limit(limit);
    if (tag) q = q.contains("tags", [tag]);
    const { data, error } = await q;
    if (error) throw new ApiError("internal", "Could not list companies.");
 
    const rows = (
      (data ?? []) as Array<{
        domain: string;
        external_id: string | null;
        tags: string[];
        added_at: string;
        last_refreshed_at: string;
        company_research: { company_data: EnrichedCompany } | null;
      }>
    ).map((r) => {
      const c = r.company_research?.company_data;
      return {
        domain: r.domain,
        external_id: r.external_id,
        tags: r.tags,
        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,
        added_at: r.added_at,
        last_refreshed_at: r.last_refreshed_at,
      };
    });
 
    if (format === "csv") {
      const cols = [
        "domain",
        "external_id",
        "company_name",
        "country",
        "industry",
        "headcount_estimate",
        "annual_labour_cost_eur",
        "potential_saving_eur",
        "confidence",
        "tags",
        "added_at",
        "last_refreshed_at",
      ];
      const esc = (v: unknown) => {
        const s = v === null || v === undefined ? "" : Array.isArray(v) ? v.join("|") : String(v);
        return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
      };
      const lines = [cols.join(",")];
      for (const r of rows)
        lines.push(cols.map((c) => esc((r as Record<string, unknown>)[c])).join(","));
      return new NextResponse(lines.join("\n"), {
        status: 200,
        headers: {
          "content-type": "text/csv; charset=utf-8",
          "content-disposition": 'attachment; filename="companies.csv"',
        },
      });
    }
 
    return NextResponse.json({ companies: rows });
  } catch (err) {
    if (err instanceof ApiError) return err.toResponse();
    // eslint-disable-next-line no-console
    console.error("[/v1/companies GET] internal", err);
    return apiError("internal", "Unexpected error.");
  }
}
  • Step 2: Typecheck. pnpm --filter @agent-metrics/web exec tsc --noEmit. The PostgREST embedded select company_research(company_data) returns a typed-as-loose object; the as cast handles it. Fix only this file if a real type error appears (e.g. the embed comes back as an array, in which case change the cast to company_research: { company_data: EnrichedCompany }[] | null and read [0]). Report which shape you used.

  • Step 3: Live smoke (real calls + DB; charges a lookup against an org). This needs a real api key + org. If one is readily available in the repo's test scripts (look in apps/web/scripts/ for an existing key/org helper) use it; otherwise SKIP the live HTTP smoke and report DONE_WITH_CONCERNS noting the route compiles and is covered by the plan's manual curl example below. Do NOT fabricate or seed a key. Manual curl for the human to run later:

curl -s -X POST "$APP_URL/v1/companies" -H "Authorization: Bearer hh_live_..." -H "content-type: application/json" -d '{"domain":"catawiki.com","tags":["nl-saas"]}' | head -c 600
curl -s "$APP_URL/v1/companies?format=csv" -H "Authorization: Bearer hh_live_..." | head
  • Step 4: Commit.
git add apps/web/app/api/v1/companies/route.ts
git commit -m "feat(api): POST + GET /v1/companies (enrich into library, list, csv export)"

Body line as above.


Self-review (plan author)

  • Spec coverage: lookup charging on new/refresh only; free view/list/export; hard cap with lookup_quota_exceeded (402) and an upgrade hint, no overage; Free lifetime 10 vs paid monthly; Agency pooled across the tree; CSV + JSON list for the outreach pull; external_id + tags for campaign matching. Uses the existing auth + error + plan-gate patterns exactly.
  • Plan-name caveat resolved: plan-gate's PlanKey (free/pro/agency/enterprise) is authoritative; no type rename needed here.
  • No placeholders: all code is concrete; the only conditional is the documented PostgREST embed shape in Task 2 Step 2.

Out of scope / next

  • GET /v1/companies/{domain}, POST /v1/companies/{domain}/refresh, a dedicated GET /v1/companies/export (Phase 3b).
  • Bulk submission + worker (Phase 4). Pricing page + tier migration (Phase 5). Frontend library UI (Phase 6).
  • Fix the known service.ts mislabel (stores loaded cost as gross_hourly_eur); align when convenient.

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