158 lines
5.2 KiB
Elixir
158 lines
5.2 KiB
Elixir
defmodule Mv.Membership.Import.MemberCSV do
|
|
@moduledoc """
|
|
Service module for importing members from CSV files.
|
|
|
|
This module provides the core API for CSV member import functionality:
|
|
- `prepare/2` - Parses and validates CSV content, returns import state
|
|
- `process_chunk/3` - Processes a chunk of rows and creates members
|
|
|
|
## Error Handling
|
|
|
|
Errors are returned as `%MemberCSV.Error{}` structs containing:
|
|
- `csv_line_number` - The physical line number in the CSV file (or `nil` for general errors)
|
|
- `field` - The field name (atom) or `nil` if not field-specific
|
|
- `message` - Human-readable error message (or `nil` for general errors)
|
|
|
|
## Import State
|
|
|
|
The `import_state` returned by `prepare/2` contains:
|
|
- `chunks` - List of row chunks ready for processing
|
|
- `column_map` - Map of canonical field names to column indices
|
|
- `custom_field_map` - Map of custom field names to column indices
|
|
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
|
|
|
## Chunk Results
|
|
|
|
The `chunk_result` returned by `process_chunk/3` contains:
|
|
- `inserted` - Number of successfully created members
|
|
- `failed` - Number of failed member creations
|
|
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
|
|
|
## Examples
|
|
|
|
# Prepare CSV for import
|
|
{:ok, import_state} = MemberCSV.prepare(csv_content)
|
|
|
|
# Process first chunk
|
|
chunk = Enum.at(import_state.chunks, 0)
|
|
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
|
"""
|
|
|
|
defmodule Error do
|
|
@moduledoc """
|
|
Error struct for CSV import errors.
|
|
|
|
## Fields
|
|
|
|
- `csv_line_number` - The physical line number in the CSV file (1-based, header is line 1)
|
|
- `field` - The field name as an atom (e.g., `:email`) or `nil` if not field-specific
|
|
- `message` - Human-readable error message
|
|
"""
|
|
defstruct csv_line_number: nil, field: nil, message: nil
|
|
|
|
@type t :: %__MODULE__{
|
|
csv_line_number: pos_integer() | nil,
|
|
field: atom() | nil,
|
|
message: String.t() | nil
|
|
}
|
|
end
|
|
|
|
@type import_state :: %{
|
|
chunks: list(list({pos_integer(), map()})),
|
|
column_map: %{atom() => non_neg_integer()},
|
|
custom_field_map: %{String.t() => non_neg_integer()},
|
|
warnings: list(String.t())
|
|
}
|
|
|
|
@type chunk_result :: %{
|
|
inserted: non_neg_integer(),
|
|
failed: non_neg_integer(),
|
|
errors: list(Error.t())
|
|
}
|
|
|
|
@doc """
|
|
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
|
|
|
This function:
|
|
1. Strips UTF-8 BOM if present
|
|
2. Detects CSV delimiter (semicolon or comma)
|
|
3. Parses headers and data rows
|
|
4. Maps headers to canonical member fields
|
|
5. Maps custom field columns by name
|
|
6. Validates row count limits
|
|
7. Chunks rows for processing
|
|
|
|
## Parameters
|
|
|
|
- `file_content` - The raw CSV file content as a string
|
|
- `opts` - Optional keyword list:
|
|
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
|
- `:chunk_size` - Number of rows per chunk (default: 200)
|
|
|
|
## Returns
|
|
|
|
- `{:ok, import_state}` - Successfully prepared import state
|
|
- `{:error, reason}` - Error reason (string or error struct)
|
|
|
|
## Examples
|
|
|
|
iex> MemberCSV.prepare("email\\njohn@example.com")
|
|
{:ok, %{chunks: [...], column_map: %{email: 0}, ...}}
|
|
|
|
iex> MemberCSV.prepare("")
|
|
{:error, "CSV file is empty"}
|
|
"""
|
|
@spec prepare(String.t(), keyword()) :: {:ok, import_state()} | {:error, String.t()}
|
|
def prepare(file_content, opts \\ []) do
|
|
# TODO: Implement in Issue #3 (CSV Parsing)
|
|
# This is a skeleton implementation that will be filled in later
|
|
_ = {file_content, opts}
|
|
|
|
# Placeholder return - will be replaced with actual implementation
|
|
{:error, "Not yet implemented"}
|
|
end
|
|
|
|
@doc """
|
|
Processes a chunk of CSV rows and creates members.
|
|
|
|
This function:
|
|
1. Validates each row
|
|
2. Creates members via Ash resource
|
|
3. Creates custom field values for each member
|
|
4. Collects errors with correct CSV line numbers
|
|
5. Returns chunk processing results
|
|
|
|
## Parameters
|
|
|
|
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
|
- `csv_line_number` - Physical line number in CSV (1-based)
|
|
- `row_map` - Map of column names to values
|
|
- `column_map` - Map of canonical field names (atoms) to column indices
|
|
- `opts` - Optional keyword list for processing options
|
|
|
|
## Returns
|
|
|
|
- `{:ok, chunk_result}` - Chunk processing results
|
|
- `{:error, reason}` - Error reason (string)
|
|
|
|
## Examples
|
|
|
|
iex> chunk = [{2, %{"email" => "john@example.com"}}]
|
|
iex> column_map = %{email: 0}
|
|
iex> MemberCSV.process_chunk(chunk, column_map)
|
|
{:ok, %{inserted: 1, failed: 0, errors: []}}
|
|
"""
|
|
@spec process_chunk(
|
|
list({pos_integer(), map()}),
|
|
%{atom() => non_neg_integer()},
|
|
keyword()
|
|
) :: {:ok, chunk_result()} | {:error, String.t()}
|
|
def process_chunk(chunk_rows_with_lines, column_map, opts \\ []) do
|
|
# TODO: Implement in Issue #6 (Persistence)
|
|
# This is a skeleton implementation that will be filled in later
|
|
_ = {chunk_rows_with_lines, column_map, opts}
|
|
|
|
# Placeholder return - will be replaced with actual implementation
|
|
{:ok, %{inserted: 0, failed: 0, errors: []}}
|
|
end
|
|
end
|