// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten // Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine // E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie // beim einzelnen "Nutzer anlegen" generiert der Import ein // Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG // sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern: // gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet // (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer // Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren). package web import ( "encoding/csv" "fmt" "io" "net/http" "strings" "github.com/netcell-it/deklarix/internal/auth" ) type csvMitarbeiterZeile struct { Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen Email string Role string } // parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile. // Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder // "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst // beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine // einzelne falsche Rolle nicht den ganzen Import verhindert). func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) { cr := csv.NewReader(r) cr.TrimLeadingSpace = true cr.FieldsPerRecord = -1 header, err := cr.Read() if err != nil { return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err) } emailCol, roleCol := -1, -1 for i, h := range header { switch strings.ToLower(strings.TrimSpace(h)) { case "email", "e-mail": emailCol = i case "role", "rolle": roleCol = i } } if emailCol == -1 { return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`) } var out []csvMitarbeiterZeile zeile := 1 for { record, err := cr.Read() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err) } zeile++ if emailCol >= len(record) { continue } email := strings.TrimSpace(record[emailCol]) if email == "" { continue } role := "mitarbeiter" if roleCol != -1 && roleCol < len(record) { if v := strings.TrimSpace(record[roleCol]); v != "" { role = v } } out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role}) } return out, nil } // generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für // eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt // einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine // Länge, die sich noch manuell weitergeben lässt. func generateEinmalPasswort() (string, error) { token, err := auth.NewSessionToken() if err != nil { return "", err } return token[:16], nil } type csvImportFormData struct { Title string Nav navData Error string } func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) { data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)} if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) { data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg} if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } type csvImportErgebnisZeile struct { Zeile int Email string Role string Passwort string // nur bei Erfolg gesetzt Fehler string // nur bei Fehler gesetzt } type csvImportErgebnisData struct { Title string Nav navData Ergebnisse []csvImportErgebnisZeile } // handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt // pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis // (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite // gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden. func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error()) return } file, _, err := r.FormFile("csv") if err != nil { s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen") return } defer file.Close() zeilen, err := parseCSVMitarbeiter(file) if err != nil { s.renderCSVImportForm(w, r, err.Error()) return } if len(zeilen) == 0 { s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen") return } accountID := currentUser(r).AccountID var ergebnisse []csvImportErgebnisZeile for _, z := range zeilen { if !istMandantenRolle(z.Role) { ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)}) continue } passwort, err := generateEinmalPasswort() if err != nil { ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"}) continue } hash, err := auth.HashPassword(passwort) if err != nil { ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()}) continue } if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil { ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"}) continue } ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort}) } data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse} if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } }