// Package csvimport parses a guest-list CSV into structured rows, with // tolerant header detection (Excel, Numbers, Google Sheets variants) and // per-row validation. Streaming-friendly so a 5,000-row import doesn't // load the entire file into a slice before we know if column 1 is junk. package csvimport import ( "bufio" "encoding/csv" "errors" "fmt" "io" "net/mail" "regexp" "strconv" "strings" "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" ) // Row is a single validated guest. Empty Email / Phone are allowed (a // phone-only or name-only guest is valid per the plan). type Row struct { Name string Email string Phone string PlusOnes int } // RowError flags one row with the human-readable reason it can't be // imported. The line number is 1-based and matches the source CSV // (header counts as line 1, first data row is line 2) so the frontend // can highlight the offending row. type RowError struct { Row int `json:"row"` Reason string `json:"reason"` } // Result is the outcome of one parse pass. type Result struct { Rows []Row `json:"rows,omitempty"` Errors []RowError `json:"errors,omitempty"` TotalCount int `json:"total_count"` // total data rows seen (excluding header) } // Options tune limits + behaviour. type Options struct { MaxRows int // hard cap; rows beyond MaxRows return an error instead of being silently dropped } const DefaultMaxRows = 5000 // Strict E.164: optional leading +, then a non-zero leading digit (country // codes never start with 0), followed by 6–14 more digits — total 7–15 // significant digits. Spaces / dashes / parens are tolerated by stripping // before validation, but local-format numbers like "0244…" or "07700…" // are rejected here so the host fixes them at upload time rather than at // WhatsApp-send time. var phoneRe = regexp.MustCompile(`^\+?[1-9][0-9]{6,14}$`) // Parse reads a CSV from r and returns the parsed result. Encoding is // auto-detected: UTF-8 with or without BOM, plus UTF-16 LE/BE BOMs // (commonly produced by Mac Numbers exports). func Parse(r io.Reader, opt Options) (*Result, error) { max := opt.MaxRows if max <= 0 { max = DefaultMaxRows } rd, err := decodingReader(r) if err != nil { return nil, err } csvr := csv.NewReader(rd) csvr.FieldsPerRecord = -1 // tolerate ragged rows; we re-validate column count ourselves csvr.TrimLeadingSpace = true header, err := csvr.Read() if err != nil { if errors.Is(err, io.EOF) { return nil, errors.New("csv is empty") } return nil, fmt.Errorf("read header: %w", err) } cols, err := detectColumns(header) if err != nil { return nil, err } out := &Result{Rows: make([]Row, 0, 64)} lineNo := 1 // header was line 1 for { rec, err := csvr.Read() if err == io.EOF { break } lineNo++ if err != nil { out.Errors = append(out.Errors, RowError{Row: lineNo, Reason: fmt.Sprintf("malformed csv: %v", err)}) continue } out.TotalCount++ if out.TotalCount > max { return nil, fmt.Errorf("import exceeds maximum of %d rows", max) } // Skip fully-empty rows silently — these appear at the end of // Excel exports a lot. if rowEmpty(rec) { out.TotalCount-- // don't count it continue } row, rerr := buildRow(rec, cols) if rerr != "" { out.Errors = append(out.Errors, RowError{Row: lineNo, Reason: rerr}) continue } out.Rows = append(out.Rows, row) } return out, nil } func rowEmpty(rec []string) bool { for _, v := range rec { if strings.TrimSpace(v) != "" { return false } } return true } // decodingReader strips a UTF-8 BOM and decodes UTF-16 LE/BE when their // BOM is present, returning a UTF-8 reader. Other byte orders fall through // as raw UTF-8. func decodingReader(r io.Reader) (*bufio.Reader, error) { br := bufio.NewReader(r) bom, err := br.Peek(3) if err != nil && !errors.Is(err, io.EOF) { return nil, err } switch { case len(bom) >= 3 && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF: _, _ = br.Discard(3) return br, nil case len(bom) >= 2 && bom[0] == 0xFF && bom[1] == 0xFE: _, _ = br.Discard(2) dec := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder() return bufio.NewReader(transform.NewReader(br, dec)), nil case len(bom) >= 2 && bom[0] == 0xFE && bom[1] == 0xFF: _, _ = br.Discard(2) dec := unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewDecoder() return bufio.NewReader(transform.NewReader(br, dec)), nil } return br, nil } // columnSet records which column index each known field lives in. -1 means // the column was not supplied; only Name is mandatory. type columnSet struct { name, email, phone, plusOnes int } func detectColumns(header []string) (columnSet, error) { cs := columnSet{name: -1, email: -1, phone: -1, plusOnes: -1} for i, raw := range header { key := normaliseHeader(raw) switch key { case "name", "guestname", "fullname": cs.name = i case "email", "emailaddress", "e-mail": cs.email = i case "phone", "telephone", "mobile", "phonenumber": cs.phone = i case "plusones", "plus1", "plus-one", "plus-ones", "+1", "guests", "additionalguests": cs.plusOnes = i } } if cs.name < 0 { return cs, fmt.Errorf("required column 'name' not found in header: %v", header) } return cs, nil } func normaliseHeader(s string) string { s = strings.ToLower(strings.TrimSpace(s)) // Drop spaces + underscores. Keep `+`, `-` so "+1" / "plus-one" still // match exactly. return strings.NewReplacer(" ", "", "_", "").Replace(s) } func buildRow(rec []string, cs columnSet) (Row, string) { get := func(i int) string { if i < 0 || i >= len(rec) { return "" } return strings.TrimSpace(rec[i]) } row := Row{ Name: get(cs.name), Email: strings.ToLower(get(cs.email)), Phone: get(cs.phone), } if row.Name == "" { return row, "name is required" } if row.Email != "" { if _, err := mail.ParseAddress(row.Email); err != nil { return row, "invalid email" } } if row.Phone != "" { stripped := stripPhone(row.Phone) if !phoneRe.MatchString(stripped) { return row, "phone must be in international format with country code (e.g. +447700900123) — local numbers starting with 0 won't work for SMS or WhatsApp" } // Normalise: ensure stored form always starts with "+". if !strings.HasPrefix(stripped, "+") { stripped = "+" + stripped } row.Phone = stripped } if raw := get(cs.plusOnes); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return row, "plus_ones must be a non-negative integer" } row.PlusOnes = n } return row, "" } var phoneStripper = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", " ", "") func stripPhone(s string) string { return phoneStripper.Replace(s) } // TemplateCSV is the sample file served at /events/{id}/guests/import/template. // Phone numbers MUST include the country code (e.g. +44 for UK, +233 for // Ghana). Local-format numbers like "0244..." or "07700..." will be // rejected at upload — the sample below shows the expected shape. const TemplateCSV = "name,email,phone,plus_ones\n" + "Alex Doe,alex@example.com,+447700900123,1\n" + "Sam Patel,sam@example.com,,0\n" + "Jordan Lee,,+15551234567,2\n" + "Mira Patel,mira@example.com,+233244123456,0\n"