import { Injectable, Logger, BadRequestException, NotFoundException, } from '@nestjs/common'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { ImportEntityType } from './dto/import-preview.dto'; import { ImportExecuteDto, DuplicateStrategy, FieldMappingDto, } from './dto/import-execute.dto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { v4 as uuidv4 } from 'uuid'; import csvParser from 'csv-parser'; import * as XLSX from 'xlsx'; import { Readable } from 'stream'; const MAX_ROWS = 5000; const PREVIEW_ROWS = 10; const TARGET_FIELDS: Record = { [ImportEntityType.CONTACT]: [ 'firstName', 'lastName', 'email', 'phone', 'mobile', 'companyName', 'position', 'department', 'website', 'street', 'zip', 'city', 'state', 'country', 'notes', 'tags', 'source', 'linkedinUrl', ], [ImportEntityType.COMPANY]: [ 'name', 'email', 'phone', 'website', 'industry', 'street', 'zip', 'city', 'state', 'country', 'vatId', 'taxId', 'tradeRegisterNumber', 'registerCourt', 'notes', 'tags', ], }; interface ParsedFile { columns: string[]; rows: Record[]; totalRows: number; format: 'csv' | 'xlsx'; } export interface ImportError { row: number; field: string; value: string; message: string; } @Injectable() export class ImportService { private readonly logger = new Logger(ImportService.name); constructor(private readonly prisma: CrmPrismaService) {} // -------------------------------------------------------- // Preview: Datei parsen und Vorschau zurueckgeben // -------------------------------------------------------- async preview( file: Express.Multer.File, entityType: ImportEntityType, delimiter?: string, ) { const ext = path.extname(file.originalname).toLowerCase(); if (!['.csv', '.xlsx', '.xls'].includes(ext)) { throw new BadRequestException( 'Nicht unterstuetztes Dateiformat. Erlaubt: .csv, .xlsx, .xls', ); } const parsed = ext === '.csv' ? await this.parseCsv(file.buffer, delimiter) : this.parseExcel(file.buffer); if (parsed.totalRows > MAX_ROWS) { throw new BadRequestException( `Datei enthaelt ${parsed.totalRows} Zeilen. Maximum: ${MAX_ROWS}`, ); } // Temp-Datei speichern (GDPR: wird nach Execute geloescht) const importId = uuidv4(); const tmpPath = path.join(os.tmpdir(), `crm-import-${importId}${ext}`); fs.writeFileSync(tmpPath, file.buffer); return { importId, format: parsed.format, columns: parsed.columns, rows: parsed.rows.slice(0, PREVIEW_ROWS), totalRows: parsed.totalRows, availableTargetFields: TARGET_FIELDS[entityType], }; } // -------------------------------------------------------- // Execute: Import ausfuehren // -------------------------------------------------------- async execute(tenantId: string, userId: string, dto: ImportExecuteDto) { const tmpDir = os.tmpdir(); const possibleExts = ['.csv', '.xlsx', '.xls']; let tmpPath: string | null = null; let ext = ''; for (const e of possibleExts) { const candidate = path.join(tmpDir, `crm-import-${dto.importId}${e}`); if (fs.existsSync(candidate)) { tmpPath = candidate; ext = e; break; } } if (!tmpPath) { throw new NotFoundException( 'Import-Datei nicht gefunden. Bitte erneut hochladen (Preview abgelaufen).', ); } try { const buffer = fs.readFileSync(tmpPath); const parsed = ext === '.csv' ? await this.parseCsv(buffer) : this.parseExcel(buffer); this.validateMapping(dto.mapping, dto.entityType); const result = dto.entityType === ImportEntityType.CONTACT ? await this.importContacts(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP) : await this.importCompanies(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP); return result; } finally { // GDPR: Temp-Datei immer loeschen try { fs.unlinkSync(tmpPath); this.logger.debug(`Temp-Datei geloescht: ${tmpPath}`); } catch { this.logger.warn(`Temp-Datei konnte nicht geloescht werden: ${tmpPath}`); } } } // -------------------------------------------------------- // Privat: CSV parsen // -------------------------------------------------------- private parseCsv(buffer: Buffer, delimiter?: string): Promise { return new Promise((resolve, reject) => { const rows: Record[] = []; let columns: string[] = []; const stream = Readable.from(buffer); stream .pipe(csvParser({ separator: delimiter || undefined, mapHeaders: ({ header }: { header: string }) => header.trim(), })) .on('headers', (headers: string[]) => { columns = headers; }) .on('data', (row: Record) => { if (rows.length < MAX_ROWS) { rows.push(row); } }) .on('end', () => { resolve({ columns, rows, totalRows: rows.length, format: 'csv' }); }) .on('error', (err: Error) => { reject(new BadRequestException(`CSV-Parse-Fehler: ${err.message}`)); }); }); } // -------------------------------------------------------- // Privat: Excel parsen // -------------------------------------------------------- private parseExcel(buffer: Buffer): ParsedFile { const workbook = XLSX.read(buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; if (!sheetName) { throw new BadRequestException('Excel-Datei enthaelt keine Arbeitsblaetter'); } const sheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json>(sheet, { defval: '', raw: false, }); if (jsonData.length === 0) { throw new BadRequestException('Excel-Datei enthaelt keine Daten'); } const columns = Object.keys(jsonData[0]); const rows = jsonData.slice(0, MAX_ROWS); return { columns, rows, totalRows: jsonData.length, format: 'xlsx' }; } // -------------------------------------------------------- // Privat: Mapping validieren // -------------------------------------------------------- private validateMapping( mapping: FieldMappingDto[], entityType: ImportEntityType, ): void { const allowed = TARGET_FIELDS[entityType]; for (const m of mapping) { if (!allowed.includes(m.targetField)) { throw new BadRequestException( `Ungueltiges Zielfeld "${m.targetField}" fuer ${entityType}. Erlaubt: ${allowed.join(', ')}`, ); } } } // -------------------------------------------------------- // Privat: Kontakte importieren // -------------------------------------------------------- private async importContacts( tenantId: string, userId: string, rows: Record[], mapping: FieldMappingDto[], duplicateStrategy: DuplicateStrategy, ) { let created = 0; let updated = 0; let skipped = 0; const errors: ImportError[] = []; for (let i = 0; i < rows.length; i++) { const rowNum = i + 2; // +2 weil Header = Zeile 1 try { const mapped = this.mapRow(rows[i], mapping); const email = (mapped['email'] ?? '').trim().toLowerCase(); // Duplikat pruefen let existingId: string | null = null; if (email) { const existing = await this.prisma.contact.findFirst({ where: { tenantId, email: { equals: email, mode: 'insensitive' }, }, }); existingId = existing?.id ?? null; } if (existingId) { switch (duplicateStrategy) { case DuplicateStrategy.SKIP: skipped++; continue; case DuplicateStrategy.UPDATE: await this.prisma.contact.update({ where: { id: existingId }, data: { ...this.buildContactData(mapped), updatedBy: userId, }, }); updated++; continue; case DuplicateStrategy.MARK: mapped['_isDuplicate'] = 'true'; break; } } const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : []; if (mapped['tags']) { tags.push(...mapped['tags'].split(',').map((t: string) => t.trim())); } await this.prisma.contact.create({ data: { tenantId, createdBy: userId, firstName: mapped['firstName'] ?? null, lastName: mapped['lastName'] ?? null, email: mapped['email'] ?? null, phone: mapped['phone'] ?? null, mobile: mapped['mobile'] ?? null, companyName: mapped['companyName'] ?? null, position: mapped['position'] ?? null, department: mapped['department'] ?? null, website: mapped['website'] ?? null, linkedinUrl: mapped['linkedinUrl'] ?? null, street: mapped['street'] ?? null, zip: mapped['zip'] ?? null, city: mapped['city'] ?? null, state: mapped['state'] ?? null, country: mapped['country'] ?? 'DE', notes: mapped['notes'] ?? null, tags, source: 'IMPORT', owners: { create: { tenantId, userId, role: 'OWNER' }, }, }, }); created++; } catch (err) { errors.push({ row: rowNum, field: '', value: '', message: err instanceof Error ? err.message : 'Unbekannter Fehler', }); } } return { created, updated, skipped, errors: errors.length, totalProcessed: rows.length, errorDetails: errors.slice(0, 50), }; } // -------------------------------------------------------- // Privat: Unternehmen importieren // -------------------------------------------------------- private async importCompanies( tenantId: string, userId: string, rows: Record[], mapping: FieldMappingDto[], duplicateStrategy: DuplicateStrategy, ) { let created = 0; let updated = 0; let skipped = 0; const errors: ImportError[] = []; for (let i = 0; i < rows.length; i++) { const rowNum = i + 2; try { const mapped = this.mapRow(rows[i], mapping); const email = (mapped['email'] ?? '').trim().toLowerCase(); let existingId: string | null = null; if (email) { const existing = await this.prisma.company.findFirst({ where: { tenantId, email: { equals: email, mode: 'insensitive' }, }, }); existingId = existing?.id ?? null; } if (existingId) { switch (duplicateStrategy) { case DuplicateStrategy.SKIP: skipped++; continue; case DuplicateStrategy.UPDATE: await this.prisma.company.update({ where: { id: existingId }, data: { ...this.buildCompanyData(mapped), updatedBy: userId, }, }); updated++; continue; case DuplicateStrategy.MARK: mapped['_isDuplicate'] = 'true'; break; } } const name = mapped['name'] ?? ''; if (!name) { errors.push({ row: rowNum, field: 'name', value: '', message: 'Unternehmensname ist Pflichtfeld', }); continue; } const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : []; if (mapped['tags']) { tags.push(...mapped['tags'].split(',').map((t: string) => t.trim())); } await this.prisma.company.create({ data: { tenantId, createdBy: userId, name, email: mapped['email'] ?? null, phone: mapped['phone'] ?? null, website: mapped['website'] ?? null, industry: mapped['industry'] ?? null, vatId: mapped['vatId'] ?? null, taxId: mapped['taxId'] ?? null, tradeRegisterNumber: mapped['tradeRegisterNumber'] ?? null, registerCourt: mapped['registerCourt'] ?? null, street: mapped['street'] ?? null, zip: mapped['zip'] ?? null, city: mapped['city'] ?? null, state: mapped['state'] ?? null, country: mapped['country'] ?? 'DE', notes: mapped['notes'] ?? null, tags, owners: { create: { tenantId, userId, role: 'OWNER' }, }, }, }); created++; } catch (err) { errors.push({ row: rowNum, field: '', value: '', message: err instanceof Error ? err.message : 'Unbekannter Fehler', }); } } return { created, updated, skipped, errors: errors.length, totalProcessed: rows.length, errorDetails: errors.slice(0, 50), }; } // -------------------------------------------------------- // Helfer // -------------------------------------------------------- private mapRow( row: Record, mapping: FieldMappingDto[], ): Record { const result: Record = {}; for (const m of mapping) { const value = row[m.sourceColumn]; if (value !== undefined && value !== '') { result[m.targetField] = value; } } return result; } private buildContactData(mapped: Record) { return { ...(mapped['firstName'] && { firstName: mapped['firstName'] }), ...(mapped['lastName'] && { lastName: mapped['lastName'] }), ...(mapped['email'] && { email: mapped['email'] }), ...(mapped['phone'] && { phone: mapped['phone'] }), ...(mapped['mobile'] && { mobile: mapped['mobile'] }), ...(mapped['companyName'] && { companyName: mapped['companyName'] }), ...(mapped['position'] && { position: mapped['position'] }), ...(mapped['department'] && { department: mapped['department'] }), ...(mapped['website'] && { website: mapped['website'] }), ...(mapped['linkedinUrl'] && { linkedinUrl: mapped['linkedinUrl'] }), ...(mapped['street'] && { street: mapped['street'] }), ...(mapped['zip'] && { zip: mapped['zip'] }), ...(mapped['city'] && { city: mapped['city'] }), ...(mapped['state'] && { state: mapped['state'] }), ...(mapped['country'] && { country: mapped['country'] }), ...(mapped['notes'] && { notes: mapped['notes'] }), }; } private buildCompanyData(mapped: Record) { return { ...(mapped['name'] && { name: mapped['name'] }), ...(mapped['email'] && { email: mapped['email'] }), ...(mapped['phone'] && { phone: mapped['phone'] }), ...(mapped['website'] && { website: mapped['website'] }), ...(mapped['industry'] && { industry: mapped['industry'] }), ...(mapped['vatId'] && { vatId: mapped['vatId'] }), ...(mapped['taxId'] && { taxId: mapped['taxId'] }), ...(mapped['tradeRegisterNumber'] && { tradeRegisterNumber: mapped['tradeRegisterNumber'] }), ...(mapped['registerCourt'] && { registerCourt: mapped['registerCourt'] }), ...(mapped['street'] && { street: mapped['street'] }), ...(mapped['zip'] && { zip: mapped['zip'] }), ...(mapped['city'] && { city: mapped['city'] }), ...(mapped['state'] && { state: mapped['state'] }), ...(mapped['country'] && { country: mapped['country'] }), ...(mapped['notes'] && { notes: mapped['notes'] }), }; } }