26 files with Zod schemas, 27 safeParse calls. All POST/PATCH handlers now validate input: - Missing required fields → 400 with structured error - Invalid types (string vs number) → 400 - Out-of-range values (probability >100, negative amounts) → 400 - Valid data still works (behavior preserved) Pattern: schema.safeParse(await req.json()) → 400 on failure
205 lines
6.8 KiB
TypeScript
205 lines
6.8 KiB
TypeScript
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
|
|
}
|