-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(companies): add CSV import feature #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
52a57ed
1561073
5074fc4
3c20d80
808b835
407280a
c26a08d
d585dc3
d0299d9
7d4a573
56f4eeb
57001e6
ad1d702
fc0c594
4ffe150
14cd220
f2484fb
bb63520
517d859
b842bd6
77089e4
6d4793d
5a60560
b57c1f9
53f421a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
@@ -41,6 +41,7 @@ import { | |
| import type { | ||
| CompanyBulkOwnerInput, | ||
| CompanyCreateInput, | ||
| CompanyImportInput, | ||
| CompanyListInput, | ||
| CompanyRow, | ||
| CompanyUpdateInput, | ||
|
|
@@ -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" }); | ||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a CSV contains a website or domain, the importer leaves Prompt for AI agents |
||
| description: row.description?.trim() || null, | ||
| source: RecordSource.IMPORT, | ||
| enrichmentStatus: EnrichmentStatus.SKIPPED, | ||
| }); | ||
| } | ||
|
|
||
| if (toCreate.length > 0) { | ||
| await this.db.company.createMany({ data: toCreate }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Imported companies never emit Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The bulk import bypasses company deduplication because it never derives Prompt for AI agents |
||
| } | ||
|
|
||
| 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") { | ||
|
|
||
| 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> | ||
| ); | ||
| } |
| 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: [] }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an empty or blank-line-only file is uploaded, Prompt for AI agents |
||
| 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; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.rowsexcludes the CSV header. Add the header offset when assignings.rowso users can locate skipped records in the original file.Prompt for AI agents