Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
52a57ed
Merge pull request #74 from trycompai/main
carhartlewis Aug 7, 2026
1561073
chore: release release
github-actions[bot] Aug 7, 2026
5074fc4
Merge pull request #75 from trycompai/release-please--branches--release
carhartlewis Aug 7, 2026
3c20d80
Merge pull request #77 from trycompai/main
carhartlewis Aug 7, 2026
808b835
Merge pull request #79 from trycompai/main
carhartlewis Aug 7, 2026
407280a
Merge pull request #81 from trycompai/main
carhartlewis Aug 7, 2026
c26a08d
Merge pull request #84 from trycompai/main
carhartlewis Aug 7, 2026
d585dc3
Merge pull request #90 from trycompai/main
carhartlewis Aug 8, 2026
d0299d9
Merge pull request #98 from trycompai/main
carhartlewis Aug 11, 2026
7d4a573
Merge pull request #107 from trycompai/main
carhartlewis Aug 11, 2026
56f4eeb
Merge pull request #116 from trycompai/main
github-actions[bot] Aug 11, 2026
57001e6
Merge pull request #119 from trycompai/main
github-actions[bot] Aug 11, 2026
ad1d702
Merge pull request #122 from trycompai/main
github-actions[bot] Aug 11, 2026
fc0c594
Merge pull request #127 from trycompai/main
github-actions[bot] Aug 11, 2026
4ffe150
Merge pull request #130 from trycompai/main
github-actions[bot] Aug 11, 2026
14cd220
Merge pull request #135 from trycompai/main
github-actions[bot] Aug 11, 2026
f2484fb
Merge pull request #141 from trycompai/main
github-actions[bot] Aug 12, 2026
bb63520
Merge pull request #161 from trycompai/main
github-actions[bot] Aug 18, 2026
517d859
Merge pull request #165 from trycompai/main
github-actions[bot] Aug 20, 2026
b842bd6
Merge pull request #168 from trycompai/main
github-actions[bot] Aug 20, 2026
77089e4
Merge pull request #172 from trycompai/main
github-actions[bot] Aug 20, 2026
6d4793d
Merge pull request #177 from trycompai/main
github-actions[bot] Aug 21, 2026
5a60560
feat(companies): add CSV import feature
R0CH3X Sep 12, 2026
b57c1f9
fix(companies): avoid possibly-undefined row in CSV import loop
R0CH3X Sep 12, 2026
53f421a
fix(companies): fix possibly-undefined header row in CSV parser
R0CH3X Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/api/src/companies/companies.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,31 @@ export const companyBulkResultOutput = z.object({
message: z.string().nullable(),
});

export const companyImportRowInput = z.object({
name: z.string(),
phone: z.string().optional(),
city: z.string().optional(),
stateCode: z.string().optional(),
industry: z.string().optional(),
subIndustry: z.string().optional(),
website: z.string().optional(),
description: z.string().optional(),
});

export type CompanyImportRowInput = z.infer<typeof companyImportRowInput>;

export const companyImportInput = z.object({
rows: z.array(companyImportRowInput).min(1).max(5000),
});

export type CompanyImportInput = z.infer<typeof companyImportInput>;

export const companyImportOutput = z.object({
created: z.number(),
skipped: z.number(),
skips: z.array(z.object({ row: z.number(), reason: z.string() })),
});

export const companyEnrichOutput = z.object({
id: z.string(),
queued: z.boolean(),
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/companies/companies.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
companyDetailOutput,
companyEnrichOutput,
companyIdInput,
companyImportInput,
companyImportOutput,
companyListInput,
companyListOutput,
companyOptionOutput,
Expand Down Expand Up @@ -186,4 +188,13 @@ export class CompaniesRouter {
) {
return this.companies.setPrimaryContact(input.companyId, input.contactId);
}

@Mutation({
input: companyImportInput,
output: companyImportOutput,
meta: restMeta("POST", "/companies/import", ["Companies"]),
})
async import(@Input() input: z.infer<typeof companyImportInput>) {
return this.companies.import(input);
}
}
42 changes: 40 additions & 2 deletions apps/api/src/companies/companies.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
type Db,
type EnrichmentStatus,
EnrichmentStatus,
type Prisma,
Prisma as PrismaNamespace,
type RecordSource,
RecordSource,
} from "@crm/db";
import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage";
import type { FieldDefinitionWithOptions } from "@crm/db/fields";
Expand Down Expand Up @@ -41,6 +41,7 @@ import {
import type {
CompanyBulkOwnerInput,
CompanyCreateInput,
CompanyImportInput,
CompanyListInput,
CompanyRow,
CompanyUpdateInput,
Expand Down Expand Up @@ -720,6 +721,43 @@ export class CompaniesService {
};
}

async import(input: CompanyImportInput) {
const toCreate: Prisma.CompanyCreateManyInput[] = [];
const skips: { row: number; reason: string }[] = [];

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

continue;
}
toCreate.push({
name,
phone: row.phone?.trim() || null,
city: row.city?.trim() || null,
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

description: row.description?.trim() || null,
source: RecordSource.IMPORT,
enrichmentStatus: EnrichmentStatus.SKIPPED,
});
}

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

@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

@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

}

this.logger.log({
message: "Companies imported",
created: toCreate.length,
skipped: skips.length,
});

return { created: toCreate.length, skipped: skips.length, skips };
}

private translate(cause: unknown, id: string): never {
if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) {
if (cause.code === "P2025") {
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/generated/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const publicProcedure = t.procedure;
import { timelineInput, timelineOutput, timelineCountsInput, timelineCountsOutput, myTasksInput, myTasksOutput, activityCreateInput, activityCreateOutput, completeInput, completeOutput } from "../activities/activities.contracts";
import { agentListOutput, agentReviseInput, agentReviseOutput, agentIdInput, agentFilesOutput, agentSaveFileInput, agentSaveFileOutput, agentByIdOutput, agentHistoryInput, agentHistoryOutput, agentActivityOutput, agentUpdateInput, agentUpdateOutput, agentDeployInput, agentDeployOutput, agentPauseOutput, agentResumeOutput, agentArchiveOutput, agentRestoreOutput, agentRemoveOutput, agentRunNowInput, agentRunNowOutput, agentRetryRunInput, agentRetryRunOutput, agentCancelRunInput, agentCancelRunOutput } from "../agent/agents.contracts";
import { apiKeyListInput, apiKeyListOutput, createApiKeyInput, createApiKeyOutput, revokeApiKeyInput, revokeApiKeyOutput } from "../api-keys/api-keys.contracts";
import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput } from "../companies/companies.contracts";
import { companyListInput, companyListOutput, companyIdInput, companyDetailOutput, companyOptionsInput, companyOptionOutput, companyCreateInput, companySummaryOutput, companyUpdateArgs, companyArchiveResultOutput, companyBulkOwnerInput, companyBulkResultOutput, companyBulkInput, companyEnrichOutput, companyResearchOutput, setPrimaryContactInput, companySetPrimaryContactOutput, companyImportInput, companyImportOutput } from "../companies/companies.contracts";
import { contactListInput, contactListOutput, contactIdInput, contactByIdOutput, contactCreateInput, contactBasicOutput, contactUpdateArgs, contactNameOutput, contactEnrichOutput, contactBulkOwnerInput, bulkResultOutput, contactBulkCompanyInput, contactBulkInput, factDecisionInput, decideFactOutput } from "../contacts/contacts.contracts";
import { conversationListInput, conversationListOutput, builderListOutput, builderResourceSearchInput, builderResourcesOutput, conversationIdInput, builderConversationDetailOutput, conversationEventsInput, conversationEventsOutput, conversationSaveInput, conversationIdOutput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, builderResponseRatingOutput, conversationShareStatusOutput, conversationShareTokenOutput, sharedConversationInput, sharedConversationOutput } from "../conversations/conversations.contracts";
import { currencySettingsOutput, setReportingCurrencyInput, setManualRateInput, removeManualRateInput } from "../currency/currency.contracts";
Expand Down Expand Up @@ -204,6 +204,10 @@ const appRouter = t.router({
setPrimaryContact: publicProcedure
.input(setPrimaryContactInput)
.output(companySetPrimaryContactOutput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any),
import: publicProcedure
.input(companyImportInput)
.output(companyImportOutput)
.mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any)
}),
contacts: t.router({
Expand Down
19 changes: 19 additions & 0 deletions apps/app/app/(app)/[slug]/companies/import-companies-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import Upload from "@carbon/icons-react/es/Upload";
import { Button } from "@crm/ui/components/button";
import { Icon } from "@crm/ui/components/icon";
import Link from "next/link";
import { useWorkspaceUrl } from "@/lib/use-workspace-url";

export function ImportCompaniesLink() {
const workspaceUrl = useWorkspaceUrl();
return (
<Button asChild variant="outline">
<Link href={workspaceUrl("/companies/import")}>
<Icon icon={Upload} data-icon="inline-start" />
Import CSV
</Link>
</Button>
);
}
74 changes: 74 additions & 0 deletions apps/app/app/(app)/[slug]/companies/import/csv-parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type ParsedCsv = { headers: string[]; rows: string[][] };

export function parseCsv(text: string): ParsedCsv {
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

return { headers: first, rows: rest.filter((r) => r.some(Boolean)) };
}

function parseRecords(text: string): string[][] {
const records: string[][] = [];
let pos = 0;

while (pos <= text.length) {
const [fields, next] = parseRecord(text, pos);
records.push(fields);
pos = next;
if (pos >= text.length) break;
pos++;
}

return records;
}

function parseRecord(text: string, start: number): [string[], number] {
const fields: string[] = [];
let pos = start;

while (pos <= text.length) {
const [field, next] = parseField(text, pos);
fields.push(field);
pos = next;
if (pos >= text.length || text[pos] === "\n") break;
pos++;
}

return [fields, pos];
}

function parseField(text: string, start: number): [string, number] {
if (text[start] !== '"') {
const end = findUnquotedEnd(text, start);
return [text.slice(start, end), end];
}

let value = "";
let pos = start + 1;

while (pos < text.length) {
if (text[pos] === '"') {
if (text[pos + 1] === '"') {
value += '"';
pos += 2;
} else {
pos++;
break;
}
} else {
value += text[pos];
pos++;
}
}

return [value, pos];
}

function findUnquotedEnd(text: string, start: number): number {
let pos = start;
while (pos < text.length && text[pos] !== "," && text[pos] !== "\n") {
pos++;
}
return pos;
}
Loading
Loading