10 KiB
CSV Member Import
Reference for how the CSV member import actually behaves. The end-to-end
LiveView test (test/mv_web/live/import_live_test.exs) and future maintenance
depend on the rules documented here.
Status: implemented (backend + LiveView UI).
Implementation:
lib/mv/membership/import/csv_parser.ex— BOM stripping, delimiter detection, physical line numberinglib/mv/membership/import/header_mapper.ex— header normalization + column mappinglib/mv/membership/import/column_resolver.ex— read-only resolution of groups + fee-type columns (preview)lib/mv/membership/import/member_csv.ex—prepare/2,process_chunk/4, validation, member creationlib/mv/membership/import/import_runner.ex— orchestration gluelib/mv_web/live/import_live.ex(+import_live/components.ex) — UI, state machine, chunk drivinglib/mv_web/controllers/import_template_controller.ex— on-the-fly template generation
Scope
Admin-only bulk creation of members from an uploaded CSV.
- Create only — no upsert/update of existing members.
- No deduplication — a duplicate email fails its row (unique constraint) and is reported as an error.
- Best-effort, row-by-row — no transactional rollback; a failed row does not abort the import.
- No background jobs — progress is driven via LiveView
handle_infochunk messages. - Errors shown in UI only — no error-CSV export.
Out of scope: upsert, mapping wizard, transactional all-or-nothing, error export, import history/audit.
UI Flow
- Route:
/admin/import(LiveViewMvWeb.ImportLive). Template downloads:/admin/import/template/enand/admin/import/template/de(dynamic controller, not static files). - Authorization: requires
can?(:create, Mv.Membership.Member). Non-admins are redirected with a "don't have permission" flash. The import section, the template controller, and thestart_importevent all enforce this. - Upload:
allow_upload(:csv_file, accept: .csv, max_entries: 1, auto_upload: true). File size limit enforced bymax_file_size. - State machine (
@import_status):idle → preview → running → done|error.- start_import parses + resolves the file and transitions to preview. This step is read-only: no members are created yet. The preview shows the column mapping, sample rows, groups that exist vs. would be created, and fee-type/unknown-column warnings.
- confirm_import begins processing and creates members chunk by chunk.
- Results: success count, failure count, error list (each with CSV line number, message, optional field), warnings, and a truncation notice when errors exceed the cap.
Limits
- Max file size: configurable via
config :mv, csv_import: [max_file_size_mb: ...](enforced byallow_upload). - Max rows: configurable via
config :mv, csv_import: [max_rows: ...], default 1000, excluding header. Enforced inMemberCSV.prepare/2; exceeding it yields an error containing"exceeds". - Chunk size: 200 rows per chunk.
- Error cap: 50 errors collected per import overall (
failedcount stays accurate;errors_truncated?flag set when exceeded).
Parsing (CsvParser.parse/1)
- Content must be valid UTF-8 (else error). Empty content / empty header row are errors.
- UTF-8 BOM is stripped first, before any header handling.
- Line endings normalized:
\r\n,\r,\nall handled. - Delimiter auto-detection: parse the header with both
;and,parsers (NimbleCSV, quote-aware), count non-empty fields each yields, pick the higher;;wins ties; default;. - Quoting: double-quote quoting;
""inside a quoted field is a literal". Newlines inside quoted fields are supported — the record keeps its start line number. - Physical line numbers: rows are returned as
{csv_line_number, values}where the line number is the physical 1-based line in the file (header is line 1, first data row is line 2). Empty lines are skipped but do not shift numbering — downstream code must use the parser's line numbers, never recompute from row index. (Test asserts an invalid row after a skipped empty line still reports its true physical line, e.g.Line 4.) - Completely empty rows are skipped. An unparsable row produces an error naming its line number.
Header Mapping & Normalization (HeaderMapper)
normalize_header/1 (applied identically to incoming headers, mapping variants, custom
field names, group names, and fee-type names):
- trim, lowercase
- transliterate German chars:
ß → ss,ä → ae,ö → oe,ü → ue(and uppercase forms) - unify hyphen variants (en dash U+2013, em dash U+2014, minus U+2212 →
-) - punctuation to spaces:
_,()[]{},/,\→ space - remove all whitespace (so
first name==firstname) - final trim
Matching is on the fully normalized string.
Required field: email. Missing it aborts prepare with a "Missing required header" error.
Unknown member-field columns: ignored (no error). If an unknown column looks like it could be a custom field that does not exist, a warning is emitted (import continues).
Duplicate headers mapping to the same canonical field (or same custom field) are an error.
Supported member fields and header variants
Source of truth is @member_field_variants_raw in header_mapper.ex. Variants below are
illustrative; matching is via normalization, so casing/hyphen/whitespace differences all collapse.
| Canonical | Example accepted headers (EN / DE) | Notes |
|---|---|---|
email (required) |
email, e-mail, e_mail, mail, e-mail-adresse / E-Mail | |
first_name |
first name, firstname / Vorname | |
last_name |
last name, lastname, surname / Nachname, Familienname | |
join_date |
join date / Beitrittsdatum | ISO-8601 date |
exit_date |
exit date / Austrittsdatum | ISO-8601 date |
notes |
notes / Notizen, Bemerkungen | |
street |
street, address / Straße, Strasse | |
house_number |
house number, house no / Hausnummer, Nr, Nr., Nummer | |
postal_code |
postal code, zip, postcode / PLZ, Postleitzahl | |
city |
city, town / Stadt, Ort | |
country |
country / Land, Staat | |
membership_fee_start_date |
membership fee start date, fee start / Beitragsbeginn | ISO-8601 date |
Special relationship columns
- groups (headers
Groups/Gruppen/Gruppe) — comma-separated group names. Names matched case-insensitively against existing groups; missing groups are auto-created during processing. A group-assignment failure fails that row (the member was already created). - membership_fee_type (headers
Fee Type,fee_type,membership_fee_type/Beitragsart) — name matched to an existingMembershipFeeType. Empty cell → default fee type (no warning). Matched name → that fee type. Unmatched name → default fee type + warning naming the value.
These columns are resolved against the DB read-only in prepare (ColumnResolver) for the
preview; the actual writes happen in process_chunk.
Fields not importable (explicitly ignored)
- membership_fee_status — computed from fee cycles; not stored. Fee-status header variants
(
Membership Fee Status,Bezahlstatus,Mitgliedsbeitragsstatus) and the DE export labelStartdatum Mitgliedsbeitragare placed in theignoredlist and never mapped. (The UI notice namesGroups/Gruppen,Fee Type/Beitragsart, and the always-ignoredBezahlstatus.)
Custom Fields
- Custom field columns are matched by the custom field name (not slug), using the same normalization. Member fields take priority on a name collision.
- Custom fields must exist in Mila before import. Unknown custom-field columns are ignored with a warning; the import still runs.
- Empty custom-field cells create no value. Values are trimmed; type-validated per the custom
field's
value_type:- string — any text (trimmed).
- integer — must parse fully (
Integer.parsewith no remainder); e.g.42,-10. - boolean — case-insensitive
true/false,1/0,yes/no,ja/nein. - date — ISO-8601
YYYY-MM-DD. - email — validated with
EctoCommons.EmailValidator(same checks as member email).
- A value failing type validation fails the row. Error message format:
custom_field: <name> – expected <type>, got: <value>(type label is the human-readableFieldTypes.label/1, with format hints for boolean/date).
Validation & Member Creation (process_chunk/4 → process_row)
Per row: validate → create member → create custom-field values → assign groups. Sequential.
- Email is required and format-validated (
EctoCommons.EmailValidator,Mv.Constants.email_validator_checks()) on a trimmed value. All string member values are trimmed. - Date fields (
join_date,exit_date,membership_fee_start_date): empty/blank strings are converted tonilso Ash accepts them. - Member created via
Mv.Membership.create_member/2. Custom field values are passed ascustom_field_values(Ash union_union_type/_union_valueformat), omitted when none. - Errors are
%MemberCSV.Error{csv_line_number, field, message}:csv_line_numberis the physical line (1-based); never recomputed in this layer.- Validation errors get
field: :email; Ash errors prefer the field-level error. - Duplicate email (unique constraint) is surfaced as a friendly
"email <addr> has already been taken"message.
- Error capping (
max_errors, default 50, tracked across chunks viaexisting_error_count): once the cap is hit, no further errors are collected but all rows are still processed and thefailedcount stays accurate;errors_truncated?is set and the UI shows a truncation notice.
Templates (ImportTemplateController)
- Generated on the fly (not static files), gated by
can?(:create, Member). - One header row: standard member columns (localized EN/DE) +
Groups/Gruppen+Fee Type/Beitragsart+ every existing custom field name appended, then one example row. - Semicolon-delimited, RFC-4180 quoting; fields run through
MembersCSV.safe_cell/1to neutralize spreadsheet formula injection (e.g. a custom-field name like=HYPERLINK(...)).