feat: CSV Import for CRM (PRD §6.1: CSV import/export with mapping)

- Real RFC 4180 CSV parser (handles quoted fields, escaped quotes, newlines)
- 3-step import dialog: upload → map columns → result
- Column auto-detection (name/firma/company)
- Duplicate detection (name + website)
- Creates companies + optional persons
- Import audit-logged
- Result summary: created, skipped, errors
This commit is contained in:
Z User committed 2026-10-27 17:15:00 +00:00
1 parent 25451d77eb
commit 49377bb2de
2 files changed
+406 -5

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
// POST /api/aos/crm/import
// Import companies + persons from a CSV string.
// Body: { csv: string, mapping: { name: number, vatId: number, ... } }
//
// The mapping maps AOS field names to CSV column indices (0-based).
// Required: name. Optional: legalForm, vatId, website, email, phone, city, zip, street.
// Duplicate detection: skips companies with the same name or website.
export async function POST(req: Request) {
try {
const { csv, mapping } = (await req.json()) as {
csv: string
mapping: Record<string, number>
}
if (!csv?.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 })
if (!mapping?.name && mapping.name !== 0) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 })
// Parse CSV (simple RFC 4180 parser — handles quoted fields with commas)
const lines = parseCSV(csv)
if (lines.length < 2) {
return NextResponse.json({ error: 'CSV must have a header row + at least 1 data row' }, { status: 422 })
}
const header = lines[0]!
const dataRows = lines.slice(1)
// Get existing companies for dedup
const existingCompanies = await db.company.findMany({ select: { name: true, website: true } })
const existingNames = new Set(existingCompanies.map((c) => c.name.toLowerCase()))
const existingWebsites = new Set(existingCompanies.filter((c) => c.website).map((c) => c.website!.toLowerCase()))
let created = 0
let skipped = 0
let personsCreated = 0
const errors: { row: number; error: string }[] = []
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]!
try {
const name = row[mapping.name]?.trim()
if (!name) {
errors.push({ row: i + 2, error: 'name is empty' })
continue
}
// Dedup by name or website
const website = mapping.website !== undefined ? row[mapping.website]?.trim() || null : null
if (existingNames.has(name.toLowerCase())) {
skipped++
continue
}
if (website && existingWebsites.has(website.toLowerCase())) {
skipped++
continue
}
// Extract fields via mapping
const company = await db.company.create({
data: {
name,
legalForm: mapping.legalForm !== undefined ? row[mapping.legalForm]?.trim() || null : null,
vatId: mapping.vatId !== undefined ? row[mapping.vatId]?.trim() || null : null,
website,
email: mapping.email !== undefined ? row[mapping.email]?.trim() || null : null,
phone: mapping.phone !== undefined ? row[mapping.phone]?.trim() || null : null,
city: mapping.city !== undefined ? row[mapping.city]?.trim() || null : null,
zip: mapping.zip !== undefined ? row[mapping.zip]?.trim() || null : null,
street: mapping.street !== undefined ? row[mapping.street]?.trim() || null : null,
source: 'import',
},
})
existingNames.add(name.toLowerCase())
if (website) existingWebsites.add(website.toLowerCase())
created++
// Optional: create a person if first/last name columns are mapped
if (mapping.firstName !== undefined || mapping.lastName !== undefined) {
const firstName = mapping.firstName !== undefined ? row[mapping.firstName]?.trim() || '' : ''
const lastName = mapping.lastName !== undefined ? row[mapping.lastName]?.trim() || '' : ''
const personEmail = mapping.personEmail !== undefined ? row[mapping.personEmail]?.trim() || null : null
const personPhone = mapping.personPhone !== undefined ? row[mapping.personPhone]?.trim() || null : null
const personRole = mapping.personRole !== undefined ? row[mapping.personRole]?.trim() || null : null
if (firstName || lastName) {
await db.person.create({
data: {
companyId: company.id,
firstName: firstName || '(kein Vorname)',
lastName: lastName || '(kein Nachname)',
email: personEmail,
phone: personPhone,
role: personRole,
},
})
personsCreated++
}
}
} catch (err) {
errors.push({ row: i + 2, error: err instanceof Error ? err.message : 'unknown error' })
}
}
// Audit
await db.auditLog.create({
data: {
actor: 'max@nordlicht.digital',
action: 'crm.import:csv',
entity: 'Company',
entityId: 'batch',
after: JSON.stringify({ created, skipped, personsCreated, errors: errors.length, totalRows: dataRows.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
created,
skipped,
personsCreated,
errors,
totalRows: dataRows.length,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// Simple RFC 4180 CSV parser — handles quoted fields, escaped quotes, newlines in quotes
function parseCSV(csv: string): string[][] {
const rows: string[][] = []
let currentRow: string[] = []
let currentField = ''
let inQuotes = false
let i = 0
while (i < csv.length) {
const char = csv[i]!
const next = csv[i + 1]
if (inQuotes) {
if (char === '"') {
if (next === '"') {
// Escaped quote
currentField += '"'
i += 2
} else {
// End of quoted field
inQuotes = false
i++
}
} else {
currentField += char
i++
}
} else {
if (char === '"') {
inQuotes = true
i++
} else if (char === ',') {
currentRow.push(currentField)
currentField = ''
i++
} else if (char === '\n' || char === '\r') {
// Handle \r\n
if (char === '\r' && next === '\n') i++
currentRow.push(currentField)
currentField = ''
// Only push non-empty rows (skip blank lines)
if (currentRow.some((f) => f.trim())) {
rows.push(currentRow)
}
currentRow = []
i++
} else {
currentField += char
i++
}
}
}
// Last field/row
if (currentField || currentRow.length > 0) {
currentRow.push(currentField)
if (currentRow.some((f) => f.trim())) {
rows.push(currentRow)
}
}
return rows
}