// Shared CRM enrichment catalog and helpers. // // Keep this in the CLI package because CRM enrichment is a CLI-first surface. // App/workflow code can import this same catalog so the agent, CLI, and future // column UI agree on provider contracts and extraction behavior. export type EnrichmentCategory = "people" | "company"; export const ENRICHMENT_CATEGORIES: readonly EnrichmentCategory[] = [ "people", "company", ] as const; export type EnrichmentFieldType = | "text" | "email" | "phone" | "url" | "number" | "boolean" | "date" | "richtext" | "file" | "user" | "enum" | "relation" | "tags" | "action"; /** Contact lookups the gateway can run for a column (person/contact only). * Mirrors `EnrichField` in src/lib/workspace/enrichment-columns.ts. */ export type EnrichField = "work_emails" | "personal_emails" | "phones"; export type EnrichmentColumnDef = { label: string; key: string; fieldType: EnrichmentFieldType; /** Dot-path into the gateway response payload to extract the value. */ apolloPath: string; /** * Canonical Dench gateway field names sent in the requiredFields contract. * Empty means the gateway should use its default backfill behavior. */ requiredFields: string[]; /** Additional dot-paths to try when apolloPath does not resolve. */ extractionFallbacks?: string[]; /** Contact enrich_fields to request — present only on contact columns * (email / phone), which must stay on the /person/contact lane. */ enrichFields?: EnrichField[]; }; export type EnrichmentInputKind = "email" | "linkedin" | "domain"; export type EnrichmentInputDef = { kind: EnrichmentInputKind; label: string; }; export type FieldCandidate = { id?: string; name: string; type: string; }; const PEOPLE_PATTERNS = /people|person|contact|lead|prospect/i; const COMPANY_PATTERNS = /company|companies|organization|account|business/i; export function detectEnrichmentCategory( objectName: string, ): EnrichmentCategory | null { if (PEOPLE_PATTERNS.test(objectName)) return "people"; if (COMPANY_PATTERNS.test(objectName)) return "company"; return null; } export const PEOPLE_ENRICHMENT_COLUMNS: readonly EnrichmentColumnDef[] = [ { label: "Full Name", key: "person.name", fieldType: "text", apolloPath: "__computed.fullName", requiredFields: ["fullName"], extractionFallbacks: ["person.name", "fullName", "person.fullName"], }, { label: "Email", key: "person.email", fieldType: "email", apolloPath: "person.email", requiredFields: ["email"], extractionFallbacks: ["email"], enrichFields: ["work_emails"], }, { label: "Work Email", key: "person.work_email", fieldType: "email", apolloPath: "person.work_email", requiredFields: ["work_email"], enrichFields: ["work_emails"], }, { label: "Personal Email", key: "person.personal_email", fieldType: "email", apolloPath: "person.personal_email", requiredFields: ["personal_email"], enrichFields: ["personal_emails"], }, { label: "Headline", key: "person.headline", fieldType: "text", apolloPath: "person.headline", requiredFields: ["headline"], extractionFallbacks: ["headline"], }, { label: "LinkedIn URL", key: "person.linkedin_url", fieldType: "url", apolloPath: "person.linkedin_url", requiredFields: ["linkedinID"], extractionFallbacks: ["linkedin_url", "URLs.linkedin", "person.URLs.linkedin"], }, { label: "Twitter URL", key: "person.twitter_url", fieldType: "url", apolloPath: "person.twitter_url", requiredFields: ["URLs"], extractionFallbacks: ["twitter_url", "URLs.twitter", "person.URLs.twitter"], }, { label: "Phone", key: "person.phone", fieldType: "phone", apolloPath: "person.contact.phone_numbers.0.sanitized_number", requiredFields: ["phone"], extractionFallbacks: ["phone", "person.phone"], enrichFields: ["phones"], }, { label: "Title", key: "person.title", fieldType: "text", apolloPath: "person.title", requiredFields: [], extractionFallbacks: ["title", "person.headline", "headline"], }, { label: "Location", key: "person.location", fieldType: "text", apolloPath: "__computed.location", requiredFields: ["location"], extractionFallbacks: ["location", "person.location"], }, ]; export const COMPANY_ENRICHMENT_COLUMNS: readonly EnrichmentColumnDef[] = [ { label: "Company Name", key: "organization.name", fieldType: "text", apolloPath: "organization.name", requiredFields: ["name"], extractionFallbacks: ["name"], }, { label: "Website URL", key: "organization.website_url", fieldType: "url", apolloPath: "organization.website_url", requiredFields: ["website"], extractionFallbacks: ["website", "organization.website"], }, { label: "Industry", key: "organization.industry", fieldType: "text", apolloPath: "organization.industry", requiredFields: ["industryList"], extractionFallbacks: ["industryList.0", "organization.industryList.0"], }, { label: "LinkedIn URL", key: "organization.linkedin_url", fieldType: "url", apolloPath: "organization.linkedin_url", requiredFields: ["linkedinID"], extractionFallbacks: ["URLs.linkedin", "organization.URLs.linkedin"], }, { label: "Total Funding", key: "organization.total_funding_printed", fieldType: "text", apolloPath: "organization.total_funding_printed", requiredFields: ["totalFunding"], extractionFallbacks: ["totalFunding", "organization.totalFunding"], }, { label: "Founded Year", key: "organization.founded_year", fieldType: "number", apolloPath: "organization.founded_year", requiredFields: ["founded"], extractionFallbacks: ["founded", "organization.founded"], }, ]; export const PEOPLE_INPUTS: readonly EnrichmentInputDef[] = [ { kind: "email", label: "Email" }, { kind: "linkedin", label: "LinkedIn URL" }, ]; export const COMPANY_INPUTS: readonly EnrichmentInputDef[] = [ { kind: "domain", label: "Website / Domain" }, { kind: "linkedin", label: "LinkedIn URL" }, ]; export type EnrichmentFieldMeta = { enrichment: { category: EnrichmentCategory; key: string; apolloPath: string; inputFieldName: string; }; }; export function getEnrichmentColumns( category: EnrichmentCategory, ): readonly EnrichmentColumnDef[] { return category === "people" ? PEOPLE_ENRICHMENT_COLUMNS : COMPANY_ENRICHMENT_COLUMNS; } export function getInputDefs( category: EnrichmentCategory, ): readonly EnrichmentInputDef[] { return category === "people" ? PEOPLE_INPUTS : COMPANY_INPUTS; } export function findEnrichmentColumn(args: { category: EnrichmentCategory; key?: string; apolloPath?: string; label?: string; }): EnrichmentColumnDef | null { const columns = getEnrichmentColumns(args.category); const normalizedLabel = args.label?.trim().toLowerCase(); return ( columns.find((column) => { return ( (args.key !== undefined && column.key === args.key) || (args.apolloPath !== undefined && column.apolloPath === args.apolloPath) || (normalizedLabel !== undefined && column.label.toLowerCase() === normalizedLabel) ); }) ?? null ); } export function isEligibleInputField( category: EnrichmentCategory, field: FieldCandidate, ): boolean { if (category === "people") { return ( field.type === "email" || /^e[-_]?mail/i.test(field.name) || /linkedin/i.test(field.name) ); } return ( /domain|website/i.test(field.name) || /linkedin/i.test(field.name) || (/^url$/i.test(field.name) && field.type === "url") ); } export function getEligibleInputFields( category: EnrichmentCategory, fields: readonly FieldCandidate[], ): FieldCandidate[] { return fields.filter((field) => isEligibleInputField(category, field)); } export function getAvailableEnrichmentCategories( objectName: string, fields: readonly FieldCandidate[], ): EnrichmentCategory[] { const detected = detectEnrichmentCategory(objectName); if (detected) return [detected]; const categoriesWithInputs = ENRICHMENT_CATEGORIES.filter( (category) => getEligibleInputFields(category, fields).length > 0, ); return categoriesWithInputs.length > 0 ? [...categoriesWithInputs] : [...ENRICHMENT_CATEGORIES]; } export function autoDetectInputField( category: EnrichmentCategory, fields: readonly FieldCandidate[], ): FieldCandidate | null { const eligibleFields = getEligibleInputFields(category, fields); if (category === "people") { const emailField = eligibleFields.find( (field) => field.type === "email" || /^e[-_]?mail/i.test(field.name), ); if (emailField) return emailField; return eligibleFields.find((field) => /linkedin/i.test(field.name)) ?? null; } const domainField = eligibleFields.find((field) => /website|domain|^url$/i.test(field.name), ); if (domainField) return domainField; return eligibleFields.find((field) => /linkedin/i.test(field.name)) ?? null; } export function inferInputKind(field: FieldCandidate): EnrichmentInputKind { if (field.type === "email" || /^e[-_]?mail/i.test(field.name)) { return "email"; } if (/linkedin/i.test(field.name)) return "linkedin"; return "domain"; } export function extractEnrichmentValue( payload: Record, column: Pick, ): string | null { const primary = extractApolloValue(payload, column.apolloPath); if (primary !== null) return primary; for (const fallback of column.extractionFallbacks ?? []) { const value = extractApolloValue(payload, fallback); if (value !== null) return value; } return null; } export function getRequiredFieldsForApolloPath( category: EnrichmentCategory, apolloPath: string, ): string[] { const column = getEnrichmentColumns(category).find( (candidate) => candidate.apolloPath === apolloPath, ); if (column) return column.requiredFields; if (category !== "people") return []; if (apolloPath.includes("personal_email")) return ["personal_email"]; if (apolloPath.includes("phone")) return ["phone"]; if (apolloPath.includes("work_email") || /(^|\.)email$/.test(apolloPath)) { return ["work_email"]; } return []; } export function buildEnrichmentMeta( category: EnrichmentCategory, column: EnrichmentColumnDef, inputFieldName: string, ): EnrichmentFieldMeta { return { enrichment: { category, key: column.key, apolloPath: column.apolloPath, inputFieldName, }, }; } export function parseEnrichmentMeta( defaultValue: string | null | undefined, ): EnrichmentFieldMeta | null { if (!defaultValue) return null; try { const parsed = JSON.parse(defaultValue) as unknown; if (!parsed || typeof parsed !== "object") return null; const enrichment = (parsed as { enrichment?: unknown }).enrichment; if (!enrichment || typeof enrichment !== "object") return null; const meta = enrichment as Record; if ( (meta.category === "people" || meta.category === "company") && typeof meta.key === "string" && typeof meta.apolloPath === "string" && typeof meta.inputFieldName === "string" ) { return parsed as EnrichmentFieldMeta; } } catch { // Not enrichment metadata. } return null; } export function extractDomain(input: string): string { const trimmed = input.trim(); if (!trimmed) return ""; try { const url = new URL( trimmed.startsWith("http") ? trimmed : `https://${trimmed}`, ); return url.hostname.replace(/^www\./, ""); } catch { return trimmed.replace(/^www\./, ""); } } function extractApolloValue( payload: Record, apolloPath: string, ): string | null { if (apolloPath === "__computed.fullName") { return computeFullName(payload); } if (apolloPath === "__computed.location") { return computeLocation(payload); } const parts = apolloPath.split("."); let current: unknown = payload; for (const part of parts) { if (current == null || typeof current !== "object") return null; const index = Number(part); if (Array.isArray(current) && !Number.isNaN(index)) { current = current[index]; } else { current = (current as Record)[part]; } } if (current == null) return null; if (typeof current === "object") return JSON.stringify(current); const value = String(current).trim(); return value.length > 0 ? value : null; } function computeFullName(payload: Record): string | null { const person = payload.person as Record | undefined; if (!person) return null; const explicit = person.name ?? person.fullName; if (typeof explicit === "string" && explicit.trim().length > 0) { return explicit.trim(); } const parts = [person.first_name, person.last_name].filter( (part): part is string => typeof part === "string" && part.trim().length > 0, ); return parts.length > 0 ? parts.join(" ") : null; } function computeLocation(payload: Record): string | null { const person = payload.person as Record | undefined; if (!person) return null; const parts = [person.city, person.state, person.country].filter(Boolean); return parts.length > 0 ? parts.join(", ") : null; }