import { NextResponse } from 'next/server' import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' export const maxDuration = 30 const importSchema = z.object({ csv: z.string().min(1), mapping: z.record(z.string(), z.number()), }) // 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 parsed = importSchema.safeParse(await req.json()) if (!parsed.success) { return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } const { csv, mapping } = parsed.data if (!csv.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 }) if (mapping.name === undefined) 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 }