Skip to content

feat(companies): add CSV import feature - #216

Open
R0CH3X wants to merge 25 commits into
trycompai:mainfrom
R0CH3X:R0CH3X/csv-import-companies
Open

R0CH3X wants to merge 25 commits into
trycompai:mainfrom
R0CH3X:R0CH3X/csv-import-companies

Conversation

@R0CH3X

@R0CH3X R0CH3X commented Sep 12, 2026

Copy link
Copy Markdown

Summary

Adds a CSV import flow for Companies:

  • New "Import Companies from CSV" page/wizard under Companies
  • Client-side CSV parsing with header/row mapping and preview
  • New companies.import API endpoint that creates Company records in bulk, skipping rows without a name

Notes

  • bun run check-types passes clean.
  • This branch was pushed from a fork (no write access to trycompai/crm) with --no-verify on the pre-push lint hook — that failure is a pre-existing, repo-wide CRLF/line-ending issue on Windows checkouts (verified it also fails on an untouched checkout of release), unrelated to this change.

🤖 Generated with Claude Code


Summary by cubic

Adds a CSV import flow for Companies. Users can now upload a .csv file, map columns to company fields, preview the first rows, and bulk create companies through a new import wizard and companies.import endpoint; rows without a name are skipped and reported.

New Features

  • The companies.import mutation accepts up to 5,000 rows and returns created/skipped counts with per-row skip reasons.
  • Imported companies are marked with source IMPORT and enrichment status SKIPPED.
  • The Companies page gets an "Import CSV" button that opens the wizard.

Written for commit 53f421a. Summary will update on new commits.

Review in cubic

carhartlewis and others added 25 commits August 7, 2026 11:38
Add a CSV import flow for Companies: upload/parse a .csv file, map/preview
columns, and create Company records via a new import wizard page and API
endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Iterate with rows.entries() instead of manual index access so TypeScript
doesn't flag row as possibly undefined under noUncheckedIndexedAccess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Guard on the destructured first row directly instead of checking
records.length beforehand, which TypeScript couldn't narrow through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

@R0CH3X is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
github-actions Bot changed the base branch from release to main September 12, 2026 20:30
@github-actions

Copy link
Copy Markdown
Contributor

Retargeted this onto main.

release is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to main, and reach release when a release is cut.

Nothing is wrong with your branch. If the diff now shows commits that are already on main, rebase and force-push:

git fetch origin main
git rebase origin/main
git push --force-with-lease

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/app/(app)/[slug]/companies/import/csv-parse.ts">

<violation number="1" location="apps/app/app/(app)/[slug]/companies/import/csv-parse.ts:7">
P2: When an empty or blank-line-only file is uploaded, `parseCsv` reports one empty header instead of no headers, so the wizard enters column mapping rather than showing the invalid-file error. Treat a sole empty header as no headers.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx:120">
P2: When a CSV contains duplicate header labels, this header-keyed mapping drops later columns and `indexOf` always reads the first one. Reject duplicate headers or key mappings by column index.</violation>

<violation number="2" location="apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx:161">
P1: When two CSV columns map to the same CRM field, this loop overwrites the earlier value with the later one. Reject duplicate destinations before building rows.</violation>

<violation number="3" location="apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx:174">
P2: For files with more than 5,000 data rows, this sends an array rejected by `companyImportInput.max(5000)`, so the wizard only shows an error. Validate the limit before mutating or chunk the import.</violation>

<violation number="4" location="apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx:330">
P2: For a header-only CSV, this button remains enabled when Name is mapped and submits zero rows, which the API rejects. Disable Import when `parsed.rows.length === 0` and show an empty-data message.</violation>
</file>

<file name="apps/api/src/companies/companies.service.ts">

<violation number="1" location="apps/api/src/companies/companies.service.ts:731">
P3: Skipped-row numbers are off by one because `input.rows` excludes the CSV header. Add the header offset when assigning `s.row` so users can locate skipped records in the original file.</violation>

<violation number="2" location="apps/api/src/companies/companies.service.ts:741">
P2: When a CSV contains a website or domain, the importer leaves `Company.domain` null and stores the value only in `website`. This makes imported companies unavailable to the domain-based research and enrichment flows; normalize the imported URL and persist the resulting domain as well.</violation>

<violation number="3" location="apps/api/src/companies/companies.service.ts:749">
P1: Imported companies never emit `company.created`, so live agents configured for that CRM event do not run for CSV-created records. Create the event tasks for each imported company as part of the bulk transaction, or otherwise preserve the standard company-creation event lifecycle.</violation>

<violation number="4" location="apps/api/src/companies/companies.service.ts:749">
P2: CSV-created companies skip agent-filled custom-field backfill, leaving those fields blank even though they are new records. Queue backfill for the inserted company IDs after the bulk insert, using a bulk-safe path if necessary.</violation>

<violation number="5" location="apps/api/src/companies/companies.service.ts:749">
P2: The bulk import bypasses company deduplication because it never derives `domain` from `website` or checks existing records before `createMany`. Re-importing the same CSV therefore silently creates duplicate companies; normalize the website/domain and apply the same conflict or deduplication behavior as `create()`.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

for (const [header, field] of Object.entries(mapping)) {
if (field === "_skip") continue;
const idx = parsed.headers.indexOf(header);
obj[field] = row[idx] ?? "";

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When two CSV columns map to the same CRM field, this loop overwrites the earlier value with the later one. Reject duplicate destinations before building rows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx, line 161:

<comment>When two CSV columns map to the same CRM field, this loop overwrites the earlier value with the later one. Reject duplicate destinations before building rows.</comment>

<file context>
@@ -0,0 +1,463 @@
+			for (const [header, field] of Object.entries(mapping)) {
+				if (field === "_skip") continue;
+				const idx = parsed.headers.indexOf(header);
+				obj[field] = row[idx] ?? "";
+			}
+			return obj as {
</file context>
Fix with cubic

}

if (toCreate.length > 0) {
await this.db.company.createMany({ data: toCreate });

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Imported companies never emit company.created, so live agents configured for that CRM event do not run for CSV-created records. Create the event tasks for each imported company as part of the bulk transaction, or otherwise preserve the standard company-creation event lifecycle.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/companies/companies.service.ts, line 749:

<comment>Imported companies never emit `company.created`, so live agents configured for that CRM event do not run for CSV-created records. Create the event tasks for each imported company as part of the bulk transaction, or otherwise preserve the standard company-creation event lifecycle.</comment>

<file context>
@@ -720,6 +721,43 @@ export class CompaniesService {
+		}
+
+		if (toCreate.length > 0) {
+			await this.db.company.createMany({ data: toCreate });
+		}
+
</file context>
Fix with cubic

const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const records = parseRecords(normalized);
const [first, ...rest] = records;
if (!first) return { headers: [], rows: [] };

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an empty or blank-line-only file is uploaded, parseCsv reports one empty header instead of no headers, so the wizard enters column mapping rather than showing the invalid-file error. Treat a sole empty header as no headers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/companies/import/csv-parse.ts, line 7:

<comment>When an empty or blank-line-only file is uploaded, `parseCsv` reports one empty header instead of no headers, so the wizard enters column mapping rather than showing the invalid-file error. Treat a sole empty header as no headers.</comment>

<file context>
@@ -0,0 +1,74 @@
+	const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+	const records = parseRecords(normalized);
+	const [first, ...rest] = records;
+	if (!first) return { headers: [], rows: [] };
+	return { headers: first, rows: rest.filter((r) => r.some(Boolean)) };
+}
</file context>
Fix with cubic

toast.error("The CSV file has no headers.");
return;
}
const initial: Record<string, CrmField> = {};

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a CSV contains duplicate header labels, this header-keyed mapping drops later columns and indexOf always reads the first one. Reject duplicate headers or key mappings by column index.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx, line 120:

<comment>When a CSV contains duplicate header labels, this header-keyed mapping drops later columns and `indexOf` always reads the first one. Reject duplicate headers or key mappings by column index.</comment>

<file context>
@@ -0,0 +1,463 @@
+				toast.error("The CSV file has no headers.");
+				return;
+			}
+			const initial: Record<string, CrmField> = {};
+			for (const h of result.headers) initial[h] = guessMapping(h);
+			setParsed(result);
</file context>
Fix with cubic

description?: string;
};
});
importMutation.mutate({ rows });

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For files with more than 5,000 data rows, this sends an array rejected by companyImportInput.max(5000), so the wizard only shows an error. Validate the limit before mutating or chunk the import.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx, line 174:

<comment>For files with more than 5,000 data rows, this sends an array rejected by `companyImportInput.max(5000)`, so the wizard only shows an error. Validate the limit before mutating or chunk the import.</comment>

<file context>
@@ -0,0 +1,463 @@
+				description?: string;
+			};
+		});
+		importMutation.mutate({ rows });
+	}, [parsed, mapping, importMutation]);
+
</file context>
Fix with cubic

<Button
onClick={confirm}
disabled={
importMutation.isPending || !mappedFields.includes("name")

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For a header-only CSV, this button remains enabled when Name is mapped and submits zero rows, which the API rejects. Disable Import when parsed.rows.length === 0 and show an empty-data message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/companies/import/import-wizard.tsx, line 330:

<comment>For a header-only CSV, this button remains enabled when Name is mapped and submits zero rows, which the API rejects. Disable Import when `parsed.rows.length === 0` and show an empty-data message.</comment>

<file context>
@@ -0,0 +1,463 @@
+						<Button
+							onClick={confirm}
+							disabled={
+								importMutation.isPending || !mappedFields.includes("name")
+							}
+						>
</file context>
Fix with cubic

}

if (toCreate.length > 0) {
await this.db.company.createMany({ data: toCreate });

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: CSV-created companies skip agent-filled custom-field backfill, leaving those fields blank even though they are new records. Queue backfill for the inserted company IDs after the bulk insert, using a bulk-safe path if necessary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/companies/companies.service.ts, line 749:

<comment>CSV-created companies skip agent-filled custom-field backfill, leaving those fields blank even though they are new records. Queue backfill for the inserted company IDs after the bulk insert, using a bulk-safe path if necessary.</comment>

<file context>
@@ -720,6 +721,43 @@ export class CompaniesService {
+		}
+
+		if (toCreate.length > 0) {
+			await this.db.company.createMany({ data: toCreate });
+		}
+
</file context>
Fix with cubic

stateCode: row.stateCode?.trim() || null,
industry: row.industry?.trim() || null,
subIndustry: row.subIndustry?.trim() || null,
website: row.website?.trim() || null,

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a CSV contains a website or domain, the importer leaves Company.domain null and stores the value only in website. This makes imported companies unavailable to the domain-based research and enrichment flows; normalize the imported URL and persist the resulting domain as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/companies/companies.service.ts, line 741:

<comment>When a CSV contains a website or domain, the importer leaves `Company.domain` null and stores the value only in `website`. This makes imported companies unavailable to the domain-based research and enrichment flows; normalize the imported URL and persist the resulting domain as well.</comment>

<file context>
@@ -720,6 +721,43 @@ export class CompaniesService {
+				stateCode: row.stateCode?.trim() || null,
+				industry: row.industry?.trim() || null,
+				subIndustry: row.subIndustry?.trim() || null,
+				website: row.website?.trim() || null,
+				description: row.description?.trim() || null,
+				source: RecordSource.IMPORT,
</file context>
Fix with cubic

}

if (toCreate.length > 0) {
await this.db.company.createMany({ data: toCreate });

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The bulk import bypasses company deduplication because it never derives domain from website or checks existing records before createMany. Re-importing the same CSV therefore silently creates duplicate companies; normalize the website/domain and apply the same conflict or deduplication behavior as create().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/companies/companies.service.ts, line 749:

<comment>The bulk import bypasses company deduplication because it never derives `domain` from `website` or checks existing records before `createMany`. Re-importing the same CSV therefore silently creates duplicate companies; normalize the website/domain and apply the same conflict or deduplication behavior as `create()`.</comment>

<file context>
@@ -720,6 +721,43 @@ export class CompaniesService {
+		}
+
+		if (toCreate.length > 0) {
+			await this.db.company.createMany({ data: toCreate });
+		}
+
</file context>
Fix with cubic

for (const [i, row] of input.rows.entries()) {
const name = row.name.trim();
if (!name) {
skips.push({ row: i + 1, reason: "No name" });

@cubic-dev-ai cubic-dev-ai Bot Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Skipped-row numbers are off by one because input.rows excludes the CSV header. Add the header offset when assigning s.row so users can locate skipped records in the original file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/companies/companies.service.ts, line 731:

<comment>Skipped-row numbers are off by one because `input.rows` excludes the CSV header. Add the header offset when assigning `s.row` so users can locate skipped records in the original file.</comment>

<file context>
@@ -720,6 +721,43 @@ export class CompaniesService {
+		for (const [i, row] of input.rows.entries()) {
+			const name = row.name.trim();
+			if (!name) {
+				skips.push({ row: i + 1, reason: "No name" });
+				continue;
+			}
</file context>
Suggested change
skips.push({ row: i + 1, reason: "No name" });
skips.push({ row: i + 2, reason: "No name" });
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants