Packages
phoenix_kit
1.7.162
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/users/custom_fields.ex
defmodule PhoenixKit.Users.CustomFields do
@moduledoc """
Context for managing custom user field definitions.
This module provides functionality to define, manage, and validate custom fields
that can be added to user profiles. Field definitions are stored as JSON in the
settings table, and actual field values are stored in the user's `custom_fields`
JSONB column.
## Field Definition Structure
Each field definition is a map with the following keys:
- `key` - Unique identifier for the field (string)
- `label` - Display label for the field (string)
- `type` - Field type: text, textarea, number, boolean, date, email, url, uuid, select, radio, checkbox
- `required` - Whether the field is required (boolean)
- `position` - Display order (integer)
- `enabled` - Whether the field is active (boolean)
- `user_accessible` - Whether users can edit this field from their settings page (boolean, default: true)
- `validation` - Optional validation rules (map)
- `default` - Default value (string)
- `options` - For select/radio/checkbox types (list of strings)
## Examples
# Get all field definitions
CustomFields.list_field_definitions()
# Add a new field
CustomFields.add_field_definition(%{
"key" => "phone",
"label" => "Phone Number",
"type" => "text",
"required" => false,
"position" => 1,
"enabled" => true
})
# Get system configuration
CustomFields.get_config()
"""
require Logger
alias PhoenixKit.Settings
@setting_key "custom_user_fields_definitions"
@supported_types ~w(text textarea number boolean date email url uuid select radio checkbox)
@uuid_regex ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
# Field Definition Management
@doc """
Returns the list of all custom field definitions.
## Examples
iex> list_field_definitions()
[
%{
"key" => "phone",
"label" => "Phone Number",
"type" => "text",
...
}
]
"""
def list_field_definitions do
case Settings.get_json_setting_cached(@setting_key, nil) do
nil ->
[]
# Handle new wrapped format: %{"fields" => [definitions]}
%{"fields" => definitions} when is_list(definitions) ->
definitions
# Handle old format: direct list (for backward compatibility)
definitions when is_list(definitions) ->
definitions
# Fallback: try reading from string field for backward compatibility
_ ->
case Settings.get_setting_cached(@setting_key) do
nil -> []
json_string when is_binary(json_string) -> parse_definitions(json_string)
definitions when is_list(definitions) -> definitions
_ -> []
end
end
end
@doc """
Returns only enabled field definitions, sorted by position.
## Examples
iex> list_enabled_field_definitions()
[%{"key" => "phone", "enabled" => true, ...}]
"""
def list_enabled_field_definitions do
list_field_definitions()
|> Enum.filter(&(&1["enabled"] == true))
|> Enum.sort_by(&(&1["position"] || 0))
end
@doc """
Returns only enabled field definitions that are user-accessible, sorted by position.
These are fields that users can view and edit from their own settings page.
Admins can always see and edit all fields regardless of this setting.
Legacy fields without the `user_accessible` key default to `true` (accessible).
## Examples
iex> list_user_accessible_field_definitions()
[%{"key" => "phone", "enabled" => true, "user_accessible" => true, ...}]
"""
def list_user_accessible_field_definitions do
list_field_definitions()
|> Enum.filter(fn field ->
# Field must be enabled
enabled = field["enabled"] == true
# user_accessible defaults to true if not set (for legacy fields)
user_accessible = Map.get(field, "user_accessible", true)
enabled && user_accessible
end)
|> Enum.sort_by(&(&1["position"] || 0))
end
@doc """
Gets a single field definition by key.
Returns `nil` if not found.
## Examples
iex> get_field_definition("phone")
%{"key" => "phone", "label" => "Phone Number", ...}
iex> get_field_definition("nonexistent")
nil
"""
def get_field_definition(key) when is_binary(key) do
list_field_definitions()
|> Enum.find(&(&1["key"] == key))
end
@doc """
Saves the complete list of field definitions.
## Examples
iex> save_field_definitions([%{"key" => "phone", ...}])
{:ok, _setting}
"""
def save_field_definitions(definitions) when is_list(definitions) do
# Wrap the list in a map to match the database schema expectation
Settings.update_json_setting(@setting_key, %{"fields" => definitions})
end
@doc """
Adds a new field definition.
Validates the field structure and ensures the key is unique.
## Examples
iex> add_field_definition(%{"key" => "phone", "label" => "Phone", "type" => "text"})
{:ok, _setting}
iex> add_field_definition(%{"key" => "phone"})
{:error, "Field with key 'phone' already exists"}
"""
def add_field_definition(field_def) when is_map(field_def) do
with :ok <- validate_field_definition(field_def),
:ok <- ensure_unique_key(field_def["key"]) do
definitions = list_field_definitions()
new_definitions = definitions ++ [normalize_field_definition(field_def)]
save_field_definitions(new_definitions)
end
end
@doc """
Updates an existing field definition.
## Examples
iex> update_field_definition("phone", %{"label" => "Mobile Number"})
{:ok, _setting}
iex> update_field_definition("nonexistent", %{})
{:error, "Field with key 'nonexistent' not found"}
"""
def update_field_definition(key, updates) when is_binary(key) and is_map(updates) do
definitions = list_field_definitions()
case Enum.find_index(definitions, &(&1["key"] == key)) do
nil ->
{:error, "Field with key '#{key}' not found"}
index ->
existing = Enum.at(definitions, index)
updated = Map.merge(existing, updates) |> normalize_field_definition()
new_definitions = List.replace_at(definitions, index, updated)
save_field_definitions(new_definitions)
end
end
@doc """
Deletes a field definition by key.
## Examples
iex> delete_field_definition("phone")
{:ok, _setting}
"""
def delete_field_definition(key) when is_binary(key) do
definitions = list_field_definitions()
new_definitions = Enum.reject(definitions, &(&1["key"] == key))
save_field_definitions(new_definitions)
end
@doc """
Reorders field definitions by updating their position values.
Accepts a list of keys in the desired order.
## Examples
iex> reorder_field_definitions(["email", "phone", "department"])
{:ok, _setting}
"""
def reorder_field_definitions(ordered_keys) when is_list(ordered_keys) do
definitions = list_field_definitions()
# Create a map of key -> position
position_map =
ordered_keys
|> Enum.with_index(1)
|> Enum.into(%{})
# Update positions
updated_definitions =
Enum.map(definitions, fn def ->
Map.put(def, "position", position_map[def["key"]] || 999)
end)
|> Enum.sort_by(& &1["position"])
save_field_definitions(updated_definitions)
end
# System Information
@doc """
Gets the current custom fields system configuration.
Returns a map with field counts.
## Examples
iex> get_config()
%{
field_count: 5,
enabled_field_count: 3
}
"""
def get_config do
definitions = list_field_definitions()
enabled_definitions = Enum.filter(definitions, &(&1["enabled"] == true))
%{
field_count: length(definitions),
enabled_field_count: length(enabled_definitions)
}
end
# Option Text Lookup (for select fields that save index)
@doc """
Gets the display text for a select field value (which is stored as index).
For select/radio/checkbox fields, the saved value is the index (0, 1, 2...)
and this function returns the actual option text.
## Examples
iex> get_option_text("favorite_color", "1")
"Blue" # if options are ["Red", "Blue", "Green"]
iex> get_option_text("nonexistent", "1")
"1" # fallback to raw value
"""
def get_option_text(field_key, index_value) when is_binary(field_key) do
case get_field_definition(field_key) do
%{"type" => type, "options" => options}
when type in ~w(select radio checkbox) and is_list(options) ->
case parse_option_index(index_value) do
nil -> index_value
index -> Enum.at(options, index) || index_value
end
_ ->
# Not a select field or field not found - return raw value
index_value
end
end
def get_option_text(_field_key, value), do: value
defp parse_option_index(value) when is_integer(value), do: value
defp parse_option_index(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} -> int
_ -> nil
end
end
defp parse_option_index(_), do: nil
# Validation
@doc """
Validates a field definition structure.
Checks for required keys, valid types, and proper structure.
## Examples
iex> validate_field_definition(%{"key" => "phone", "type" => "text"})
:ok
iex> validate_field_definition(%{"key" => "phone", "type" => "invalid"})
{:error, "Invalid field type: invalid"}
"""
def validate_field_definition(field_def) when is_map(field_def) do
with :ok <- validate_required_keys(field_def),
:ok <- validate_field_type(field_def["type"]),
:ok <- validate_field_key(field_def["key"]) do
validate_options_for_type(field_def)
end
end
@doc """
Validates a user's custom field value against a field definition.
## Examples
iex> validate_custom_field_value(%{"type" => "email", "required" => true}, "test@example.com")
:ok
iex> validate_custom_field_value(%{"type" => "email", "required" => true}, nil)
{:error, "Field is required"}
"""
def validate_custom_field_value(field_def, value) do
with :ok <- validate_required(field_def, value) do
validate_type(field_def, value)
end
end
@doc """
Validates all custom fields for a user against field definitions.
Returns `:ok` if valid, or `{:error, errors}` with a map of field keys to error messages.
## Examples
iex> validate_user_custom_fields(%User{custom_fields: %{"phone" => "555-1234"}})
:ok
iex> validate_user_custom_fields(%User{custom_fields: %{"email" => "invalid"}})
{:error, %{"email" => "Invalid email format"}}
"""
def validate_user_custom_fields(user) do
definitions = list_enabled_field_definitions()
custom_fields = user.custom_fields || %{}
errors =
Enum.reduce(definitions, %{}, fn field_def, acc ->
key = field_def["key"]
value = Map.get(custom_fields, key)
case validate_custom_field_value(field_def, value) do
:ok -> acc
{:error, message} -> Map.put(acc, key, message)
end
end)
if map_size(errors) == 0 do
:ok
else
{:error, errors}
end
end
# Private Helpers
defp parse_position(nil), do: 0
defp parse_position(pos) when is_integer(pos), do: pos
defp parse_position(pos) when is_binary(pos) do
case Integer.parse(pos) do
{n, _} -> n
:error -> 0
end
end
defp parse_position(_), do: 0
defp parse_definitions(json_string) do
case Jason.decode(json_string) do
{:ok, definitions} when is_list(definitions) -> definitions
_ -> []
end
end
defp normalize_field_definition(field_def) do
%{
"key" => field_def["key"],
"label" => field_def["label"] || field_def["key"],
"type" => field_def["type"] || "text",
"required" => normalize_boolean(field_def["required"], false),
"position" => field_def["position"] || 0,
"enabled" => normalize_boolean(Map.get(field_def, "enabled"), true),
"user_accessible" => normalize_boolean(Map.get(field_def, "user_accessible"), true),
"validation" => field_def["validation"] || %{},
"default" => field_def["default"] || "",
"options" => field_def["options"] || []
}
end
# Convert string boolean values to actual booleans
defp normalize_boolean(value, default) do
case value do
true -> true
false -> false
"true" -> true
"false" -> false
nil -> default
_ -> default
end
end
defp ensure_unique_key(key) do
if get_field_definition(key) do
{:error, "Field with key '#{key}' already exists"}
else
:ok
end
end
defp validate_required_keys(field_def) do
required = ["key", "type"]
missing =
Enum.filter(required, fn key ->
is_nil(field_def[key]) or field_def[key] == ""
end)
if Enum.empty?(missing) do
:ok
else
{:error, "Missing required fields: #{Enum.join(missing, ", ")}"}
end
end
defp validate_field_type(type) when type in @supported_types, do: :ok
defp validate_field_type(type),
do: {:error, "Invalid field type: #{type}. Supported: #{Enum.join(@supported_types, ", ")}"}
defp validate_field_key(key) when is_binary(key) do
if String.match?(key, ~r/^[a-z][a-z0-9_]*$/) do
:ok
else
{:error,
"Field key must start with lowercase letter and contain only lowercase letters, numbers, and underscores"}
end
end
defp validate_field_key(_), do: {:error, "Field key must be a string"}
defp validate_options_for_type(%{"type" => type, "options" => options})
when type in ~w(select radio checkbox) do
if is_list(options) and options != [] do
:ok
else
{:error, "#{String.capitalize(type)} fields require at least one option"}
end
end
defp validate_options_for_type(%{"type" => type})
when type in ~w(select radio checkbox) do
{:error, "#{String.capitalize(type)} fields require at least one option"}
end
defp validate_options_for_type(_), do: :ok
defp validate_required(field_def, value) do
if field_def["required"] && (is_nil(value) || value == "") do
{:error, "Field is required"}
else
:ok
end
end
@doc """
Ensures field definitions exist for all keys in the given custom fields map.
For any key that doesn't have a corresponding field definition, auto-creates one
with a label derived from the key, type inferred from the value, and
`user_accessible: false` (admin-only by default).
Returns `:ok`. Logs warnings for any definitions that fail to create.
"""
def ensure_definitions_exist(custom_fields) when is_map(custom_fields) do
definitions = list_field_definitions()
existing_keys = MapSet.new(definitions, & &1["key"])
new_keys =
custom_fields
|> Map.keys()
|> Enum.reject(&MapSet.member?(existing_keys, &1))
if new_keys != [] do
next_position =
definitions
|> Enum.map(&parse_position(&1["position"]))
|> Enum.max(fn -> 0 end)
|> Kernel.+(1)
new_keys
|> Enum.with_index(next_position)
|> Enum.each(fn {key, pos} ->
label =
key
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
case add_field_definition(%{
"key" => key,
"label" => label,
"type" => infer_field_type(Map.get(custom_fields, key)),
"enabled" => true,
"user_accessible" => false,
"position" => pos
}) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.warning(
"Failed to auto-register field definition '#{key}': #{inspect(reason)}"
)
end
end)
end
:ok
end
@doc """
Infers the custom field type from a value.
Returns one of: `"boolean"`, `"number"`, `"uuid"`, `"url"`, `"email"`, `"text"`.
"""
def infer_field_type(value) when is_boolean(value), do: "boolean"
def infer_field_type(value) when is_number(value), do: "number"
def infer_field_type(value) when is_binary(value) do
cond do
Regex.match?(@uuid_regex, value) -> "uuid"
String.match?(value, ~r/^https?:\/\//) -> "url"
String.match?(value, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) -> "email"
true -> "text"
end
end
def infer_field_type(_), do: "text"
defp validate_type(_field_def, nil), do: :ok
defp validate_type(_field_def, ""), do: :ok
defp validate_type(%{"type" => "email"} = _field_def, value) do
if String.match?(value, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) do
:ok
else
{:error, "Invalid email format"}
end
end
defp validate_type(%{"type" => "url"} = _field_def, value) do
if String.match?(value, ~r/^https?:\/\/.+/) do
:ok
else
{:error, "Invalid URL format"}
end
end
defp validate_type(%{"type" => "number"} = _field_def, value) do
case Float.parse(to_string(value)) do
{_num, ""} -> :ok
_ -> {:error, "Must be a valid number"}
end
end
defp validate_type(%{"type" => "boolean"} = _field_def, value) when is_boolean(value), do: :ok
defp validate_type(%{"type" => "boolean"} = _field_def, value)
when value in ["true", "false"],
do: :ok
defp validate_type(%{"type" => "boolean"}, _value), do: {:error, "Must be true or false"}
defp validate_type(%{"type" => "uuid"} = _field_def, value) do
if Regex.match?(@uuid_regex, to_string(value)) do
:ok
else
{:error, "Invalid UUID format"}
end
end
defp validate_type(%{"type" => "date"} = _field_def, value) do
case Date.from_iso8601(to_string(value)) do
{:ok, _date} -> :ok
_ -> {:error, "Invalid date format (use YYYY-MM-DD)"}
end
end
defp validate_type(_field_def, _value), do: :ok
end