Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_entities.ex
defmodule PhoenixKitEntities do
@moduledoc """
Dynamic entity system for PhoenixKit.
This module provides both the Ecto schema definition and business logic for
managing custom content types (entities) with flexible field schemas.
## Schema Fields
- `name`: Unique identifier for the entity (e.g., "brand", "product")
- `display_name`: Human-readable singular name shown in UI (e.g., "Brand")
- `display_name_plural`: Human-readable plural name (e.g., "Brands")
- `description`: Description of what this entity represents
- `icon`: Icon identifier for UI display (hero icons)
- `status`: Workflow status string - one of "draft", "published", or "archived"
- `fields_definition`: JSONB array of field definitions
- `settings`: JSONB map of entity-specific settings
- `created_by`: User ID of the admin who created the entity
- `date_created`: When the entity was created
- `date_updated`: When the entity was last modified
## Field Definition Structure
Each field in `fields_definition` is a map with:
- `type`: Field type (text, textarea, number, boolean, date, select, etc.)
- `key`: Unique field identifier (snake_case)
- `label`: Display label for the field
- `required`: Whether the field is required
- `default`: Default value
- `validation`: Map of validation rules
- `options`: Array of options (for select, radio, checkbox types)
## Core Functions
### Entity Management
- `list_entities/0` - Get all entities
- `list_active_entities/0` - Get only active entities
- `get_entity!/1` - Get an entity by ID (raises if not found)
- `get_entity_by_name/1` - Get an entity by its name
- `create_entity/1` - Create a new entity
- `update_entity/2` - Update an existing entity
- `delete_entity/1` - Delete an entity (and all its data)
- `change_entity/2` - Get changeset for forms
### System Settings
- `enabled?/0` - Check if entities system is enabled
- `enable_system/0` - Enable the entities system
- `disable_system/0` - Disable the entities system
- `get_config/0` - Get current system configuration
- `get_max_per_user/0` - Get max entities per user limit
- `validate_user_entity_limit/1` - Check if user can create more entities
## Usage Examples
# Check if system is enabled
if PhoenixKitEntities.enabled?() do
# System is active
end
# Create a brand entity
# Note: fields_definition requires string keys, not atom keys
{:ok, entity} = PhoenixKitEntities.create_entity(%{
name: "brand",
display_name: "Brand",
display_name_plural: "Brands",
description: "Brand content type for company profiles",
icon: "hero-building-office",
created_by_uuid: admin_user.uuid,
fields_definition: [
%{"type" => "text", "key" => "name", "label" => "Name", "required" => true},
%{"type" => "textarea", "key" => "tagline", "label" => "Tagline"},
%{"type" => "rich_text", "key" => "description", "label" => "Description", "required" => true},
%{"type" => "select", "key" => "industry", "label" => "Industry",
"options" => ["Technology", "Manufacturing", "Retail"]},
%{"type" => "date", "key" => "founded_date", "label" => "Founded Date"},
%{"type" => "boolean", "key" => "featured", "label" => "Featured Brand"}
]
})
# Get entity by name
entity = PhoenixKitEntities.get_entity_by_name("brand")
# List all active entities
entities = PhoenixKitEntities.list_active_entities()
"""
use Ecto.Schema
use PhoenixKit.Module
import Ecto.Changeset
import Ecto.Query, warn: false
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.User
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Multilang
alias PhoenixKit.Utils.UUID, as: UUIDUtils
alias PhoenixKitEntities.EntityData
alias PhoenixKitEntities.Events
alias PhoenixKitEntities.Mirror.Exporter
alias PhoenixKitEntities.Mirror.Storage
@type t :: %__MODULE__{}
@primary_key {:uuid, UUIDv7, autogenerate: true}
@valid_statuses ~w(draft published archived)
@derive {Jason.Encoder,
only: [
:uuid,
:name,
:display_name,
:display_name_plural,
:description,
:icon,
:status,
:fields_definition,
:settings,
:position,
:date_created,
:date_updated
]}
schema "phoenix_kit_entities" do
field(:name, :string)
field(:display_name, :string)
field(:display_name_plural, :string)
field(:description, :string)
field(:icon, :string)
field(:status, :string, default: "published")
field(:fields_definition, {:array, :map})
field(:settings, :map)
field(:position, :integer, default: 0)
field(:created_by_uuid, UUIDv7)
field(:date_created, :utc_datetime)
field(:date_updated, :utc_datetime)
belongs_to(:creator, User,
foreign_key: :created_by_uuid,
references: :uuid,
define_field: false,
type: UUIDv7
)
has_many(:entity_data, PhoenixKitEntities.EntityData,
foreign_key: :entity_uuid,
references: :uuid
)
end
@doc """
Creates a changeset for entity creation and updates.
Validates that name is unique, fields_definition is valid, and all required fields are present.
Automatically sets date_created on new records.
"""
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(entity, attrs) do
entity
|> cast(attrs, [
:name,
:display_name,
:display_name_plural,
:description,
:icon,
:status,
:fields_definition,
:settings,
:position,
:created_by_uuid,
:date_created,
:date_updated
])
|> validate_required([:name, :display_name, :display_name_plural])
|> validate_creator_reference()
|> validate_length(:name, min: 2, max: 50)
|> validate_length(:display_name, min: 2, max: 100)
|> validate_length(:display_name_plural, min: 2, max: 100)
|> validate_length(:description, max: 500)
|> validate_inclusion(:status, @valid_statuses)
|> validate_format(:name, ~r/^[a-z][a-z0-9_]*$/,
message:
"must start with a letter and contain only lowercase letters, numbers, and underscores"
)
|> validate_name_uniqueness()
|> validate_fields_definition()
|> unique_constraint(:name)
|> maybe_set_timestamps()
end
defp validate_creator_reference(changeset) do
created_by_uuid = get_field(changeset, :created_by_uuid)
if is_nil(created_by_uuid) do
add_error(
changeset,
:created_by_uuid,
"created_by_uuid must be present"
)
else
changeset
end
end
defp validate_name_uniqueness(changeset) do
name = get_field(changeset, :name)
current_uuid = get_field(changeset, :uuid)
if is_nil(name) or name == "" do
changeset
else
case get_entity_by_name(name) do
nil -> changeset
existing when current_uuid != nil and existing.uuid == current_uuid -> changeset
_existing -> add_error(changeset, :name, "has already been taken")
end
end
end
defp validate_fields_definition(changeset) do
case get_field(changeset, :fields_definition) do
nil ->
put_change(changeset, :fields_definition, [])
fields when is_list(fields) ->
validate_each_field_definition(changeset, fields)
_invalid ->
add_error(changeset, :fields_definition, "must be a list of field definitions")
end
end
defp validate_each_field_definition(changeset, fields) do
Enum.reduce(fields, changeset, fn field, acc ->
validate_single_field_definition(acc, field)
end)
end
defp validate_single_field_definition(changeset, field) when is_map(field) do
required_keys = ["type", "key", "label"]
missing_keys = required_keys -- Map.keys(field)
if Enum.empty?(missing_keys) do
validate_field_type(changeset, field)
else
add_error(
changeset,
:fields_definition,
"field missing required keys: #{Enum.join(missing_keys, ", ")}"
)
end
end
defp validate_single_field_definition(changeset, _invalid) do
add_error(changeset, :fields_definition, "each field must be a map")
end
defp validate_field_type(changeset, field) do
valid_types =
~w(text textarea number boolean date email url select radio checkbox rich_text image file relation)
if field["type"] in valid_types do
changeset
else
add_error(
changeset,
:fields_definition,
"invalid field type '#{field["type"]}' for field '#{field["key"]}'"
)
end
end
defp maybe_set_timestamps(changeset) do
now = UtilsDate.utc_now()
case changeset.data.__meta__.state do
:built ->
changeset
|> put_change(:date_created, now)
|> put_change(:date_updated, now)
:loaded ->
put_change(changeset, :date_updated, now)
end
end
defp notify_entity_event({:ok, %__MODULE__{} = entity}, :created, opts) do
Events.broadcast_entity_created(entity.uuid)
maybe_mirror_entity(entity)
log_entity_activity(entity, "entity.created", opts)
{:ok, entity}
end
defp notify_entity_event({:ok, %__MODULE__{} = entity}, :updated, opts) do
Events.broadcast_entity_updated(entity.uuid)
maybe_mirror_entity(entity)
log_entity_activity(entity, "entity.updated", opts)
{:ok, entity}
end
defp notify_entity_event({:ok, %__MODULE__{} = entity}, :deleted, opts) do
Events.broadcast_entity_deleted(entity.uuid)
maybe_delete_mirrored_entity(entity)
log_entity_activity(entity, "entity.deleted", opts)
{:ok, entity}
end
defp notify_entity_event({:error, _} = result, event, opts) do
log_entity_error_activity(event, opts)
result
end
defp notify_entity_event(result, _event, _opts), do: result
# Records an entity-lifecycle activity entry. The actor UUID comes
# from the caller's `:actor_uuid` opt (the user performing the
# mutation) rather than `entity.created_by_uuid` (the original
# creator) — they are not the same person on update/delete.
# Non-crashing — see `PhoenixKitEntities.ActivityLog` for the guard
# semantics.
defp log_entity_activity(%__MODULE__{} = entity, action, opts) do
PhoenixKitEntities.ActivityLog.log(%{
action: action,
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid) || entity.created_by_uuid,
resource_type: "entity",
resource_uuid: entity.uuid,
metadata: %{
"name" => entity.name,
"display_name" => entity.display_name,
"status" => entity.status
}
})
end
# Records the user-initiated action even when the changeset failed,
# so the audit trail covers attempts (not just successes). Marked
# with `db_pending: true` so consumers can distinguish from
# successful rows.
defp log_entity_error_activity(event, opts) do
PhoenixKitEntities.ActivityLog.log(%{
action: "entity.#{event}",
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: "entity",
metadata: %{"db_pending" => true}
})
end
# Mirror export helpers for auto-sync (per-entity settings).
# Filesystem export is fire-and-forget after the DB commit returns;
# supervised under PhoenixKit.TaskSupervisor so a crashing exporter
# doesn't take down the caller and the task is restartable.
defp maybe_mirror_entity(entity) do
if mirror_definitions_enabled?(entity) do
Task.Supervisor.start_child(PhoenixKit.TaskSupervisor, fn ->
Exporter.export_entity(entity)
end)
end
end
defp maybe_delete_mirrored_entity(entity) do
# Delete the file if it exists (regardless of current setting)
# This ensures cleanup when entity is deleted
if Storage.entity_exists?(entity.name) do
Task.Supervisor.start_child(PhoenixKit.TaskSupervisor, fn ->
Storage.delete_entity(entity.name)
end)
end
end
@doc """
Returns the list of entities ordered by creation date.
## Examples
iex> PhoenixKitEntities.list_entities()
[%PhoenixKit.Entities{}, ...]
"""
@spec list_entities(keyword()) :: [t()]
def list_entities(opts \\ []) do
__MODULE__
|> order_by([e], asc: e.position, desc: e.date_created)
|> preload([:creator])
|> repo().all()
|> maybe_resolve_langs(opts)
end
@doc """
Returns the list of active (published) entities.
## Examples
iex> PhoenixKitEntities.list_active_entities()
[%PhoenixKit.Entities{status: "published"}, ...]
"""
@spec list_active_entities(keyword()) :: [t()]
def list_active_entities(opts \\ []) do
from(e in __MODULE__,
where: e.status == "published",
order_by: [asc: e.position, desc: e.date_created],
preload: [:creator]
)
|> repo().all()
|> maybe_resolve_langs(opts)
end
@doc """
Returns a lightweight list of published entity summaries for sidebar display.
Selects only sidebar-relevant fields without preloading associations.
Supports `:lang` option for translation resolution.
"""
@spec list_entity_summaries(keyword()) :: [map()]
def list_entity_summaries(opts \\ []) do
summaries =
from(e in __MODULE__,
where: e.status == "published",
order_by: [asc: e.position, desc: e.date_created],
select: %{
name: e.name,
display_name: e.display_name,
display_name_plural: e.display_name_plural,
description: e.description,
icon: e.icon,
settings: e.settings
}
)
|> repo().all()
case Keyword.get(opts, :lang) do
nil -> summaries
lang -> Enum.map(summaries, &resolve_summary_language(&1, lang))
end
end
# Resolves display_name / display_name_plural / description on a summary map,
# matching the behaviour of resolve_language/2 without a struct round-trip.
defp resolve_summary_language(summary, lang_code) do
translations_map = get_in(summary, [:settings, "translations"]) || %{}
translations = lookup_translation(translations_map, lang_code)
summary
|> maybe_put_translation(:display_name, translations["display_name"])
|> maybe_put_translation(:display_name_plural, translations["display_name_plural"])
|> maybe_put_translation(:description, translations["description"])
end
defp maybe_put_translation(summary, _field, nil), do: summary
defp maybe_put_translation(summary, _field, ""), do: summary
defp maybe_put_translation(summary, field, value), do: Map.put(summary, field, value)
# Looks up translation overrides by locale, tolerating base/dialect mismatches.
#
# Translations are stored under whatever key `set_entity_translation` saw —
# typically the dialect form (e.g. `"es-ES"`). Callers may query with either
# the dialect (`Gettext.get_locale/1` returns `"en-US"` etc.) or a base code
# (URL params expose `"en"`). Without normalization the dialect/base mismatch
# silently misses and the UI falls back to primary-language labels.
#
# Match priority:
# 1. Exact key match (`"es-ES"` → `"es-ES"`).
# 2. Same base code (`"es"` → first `"es-*"` translation, deterministic via
# sort).
defp lookup_translation(translations_map, lang_code)
when is_map(translations_map) and is_binary(lang_code) do
case Map.get(translations_map, lang_code) do
%{} = exact ->
exact
_ ->
base = safe_extract_base(lang_code)
translations_map
|> Enum.filter(fn {key, _v} ->
is_binary(key) and safe_extract_base(key) == base and base != nil
end)
|> Enum.sort_by(&elem(&1, 0))
|> case do
[{_key, value} | _] when is_map(value) -> value
_ -> %{}
end
end
end
defp lookup_translation(_translations_map, _lang_code), do: %{}
defp safe_extract_base(code) when is_binary(code) and code != "" do
DialectMapper.extract_base(code)
rescue
_ -> nil
end
defp safe_extract_base(_), do: nil
@doc """
Gets a single entity by integer ID or UUID.
Returns the entity if found, nil otherwise.
Accepts:
- Integer ID (e.g., 123)
- UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000")
- Integer string (e.g., "123")
## Examples
iex> PhoenixKitEntities.get_entity(123)
%PhoenixKit.Entities{}
iex> PhoenixKitEntities.get_entity("550e8400-e29b-41d4-a716-446655440000")
%PhoenixKit.Entities{}
iex> PhoenixKitEntities.get_entity(456)
nil
"""
@spec get_entity(term(), keyword()) :: t() | nil
def get_entity(uuid, opts \\ [])
def get_entity(uuid, opts) when is_binary(uuid) do
if UUIDUtils.valid?(uuid) do
case repo().get_by(__MODULE__, uuid: uuid) do
nil -> nil
entity -> entity |> repo().preload(:creator) |> maybe_resolve_lang(opts)
end
else
nil
end
end
def get_entity(_, _opts), do: nil
@doc """
Gets a single entity by integer ID or UUID.
Raises `Ecto.NoResultsError` if the entity does not exist.
## Examples
iex> PhoenixKitEntities.get_entity!(123)
%PhoenixKit.Entities{}
iex> PhoenixKitEntities.get_entity!(456)
** (Ecto.NoResultsError)
"""
@spec get_entity!(term(), keyword()) :: t()
def get_entity!(id, opts \\ []) do
case get_entity(id, opts) do
nil -> raise Ecto.NoResultsError, queryable: __MODULE__
entity -> entity
end
end
@doc """
Gets a single entity by its unique name.
Returns the entity if found, nil otherwise.
## Examples
iex> PhoenixKitEntities.get_entity_by_name("brand")
%PhoenixKit.Entities{}
iex> PhoenixKitEntities.get_entity_by_name("invalid")
nil
"""
@spec get_entity_by_name(String.t(), keyword()) :: t() | nil
def get_entity_by_name(name, opts \\ []) when is_binary(name) do
case repo().get_by(__MODULE__, name: name) do
nil -> nil
entity -> maybe_resolve_lang(entity, opts)
end
end
@doc """
Creates an entity.
## Examples
iex> PhoenixKitEntities.create_entity(%{name: "brand", display_name: "Brand"})
{:ok, %PhoenixKit.Entities{}}
iex> PhoenixKitEntities.create_entity(%{name: ""})
{:error, %Ecto.Changeset{}}
Note: `created_by` is auto-filled with the first admin or user ID if not provided,
but only if at least one user exists in the system. If no users exist, the changeset
will fail with a validation error on `created_by`.
"""
@spec create_entity(map(), keyword()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def create_entity(attrs \\ %{}, opts \\ []) do
attrs =
attrs
|> maybe_add_created_by()
|> maybe_add_entity_position()
%__MODULE__{}
|> changeset(attrs)
|> repo().insert()
|> notify_entity_event(:created, opts)
end
# Auto-assign a position when the caller hasn't specified one — places
# the new entity at the end of the manual-order list. Existing tests /
# callers that pass `:position` keep control.
# Race-tolerant: two concurrent inserts can read the same MAX and both
# write n+1; ties resolve via the date_created secondary sort and
# clear on the next drag.
defp maybe_add_entity_position(attrs) when is_map(attrs) do
has_position =
Map.has_key?(attrs, :position) or Map.has_key?(attrs, "position")
if has_position do
attrs
else
Map.put(attrs, :position, next_entity_position())
end
end
# Auto-fill created_by_uuid with first admin if not provided
defp maybe_add_created_by(attrs) when is_map(attrs) do
has_created_by_uuid =
Map.has_key?(attrs, :created_by_uuid) or Map.has_key?(attrs, "created_by_uuid")
if has_created_by_uuid do
attrs
else
creator_uuid = Auth.get_first_admin_uuid() || Auth.get_first_user_uuid()
if creator_uuid, do: Map.put(attrs, :created_by_uuid, creator_uuid), else: attrs
end
end
@doc """
Updates an entity.
## Examples
iex> PhoenixKitEntities.update_entity(entity, %{display_name: "Updated"})
{:ok, %PhoenixKit.Entities{}}
iex> PhoenixKitEntities.update_entity(entity, %{name: ""})
{:error, %Ecto.Changeset{}}
"""
@spec update_entity(t(), map(), keyword()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_entity(%__MODULE__{} = entity, attrs, opts \\ []) do
entity
|> changeset(attrs)
|> repo().update()
|> notify_entity_event(:updated, opts)
end
@doc """
Deletes an entity.
Note: This will also delete all associated entity_data records due to the
ON DELETE CASCADE constraint defined in the database migration (V17).
## Examples
iex> PhoenixKitEntities.delete_entity(entity)
{:ok, %PhoenixKit.Entities{}}
iex> PhoenixKitEntities.delete_entity(entity)
{:error, %Ecto.Changeset{}}
"""
@spec delete_entity(t(), keyword()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def delete_entity(%__MODULE__{} = entity, opts \\ []) do
repo().delete(entity)
|> notify_entity_event(:deleted, opts)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking entity changes.
## Examples
iex> PhoenixKitEntities.change_entity(entity)
%Ecto.Changeset{data: %PhoenixKit.Entities{}}
"""
@spec change_entity(t(), map()) :: Ecto.Changeset.t()
def change_entity(%__MODULE__{} = entity, attrs \\ %{}) do
changeset(entity, attrs)
end
@doc """
Returns the next available `position` for a new entity — i.e. one
past the highest currently used. Falls back to `1` when the table is
empty.
"""
@spec next_entity_position() :: integer()
def next_entity_position do
max =
__MODULE__
|> select([e], max(e.position))
|> repo().one()
case max do
nil -> 1
n when is_integer(n) -> n + 1
end
end
@doc """
Re-indexes the supplied list of entity UUIDs into positions `1..N`
in the order given.
This is the entry point for the drag-and-drop reorder event from the
Entities admin LV. UUIDs not in the list are left at their current
positions; missing UUIDs in the table are silently skipped (the LV
always sends the full visible list, so partial sends are a stale-DOM
artifact and not worth blowing up over).
Wraps both passes in a single transaction. Returns `:ok` on success
or `{:error, reason}` on transaction failure.
"""
# Same cap as `EntityData.bulk_update_positions/2`. Reorder is a
# per-page action; even a workspace with hundreds of entities would
# never paint a thousand at once.
@reorder_entities_max_uuids 1000
@spec reorder_entities([Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}
def reorder_entities(ordered_uuids, opts \\ [])
def reorder_entities(ordered_uuids, opts)
when is_list(ordered_uuids) and length(ordered_uuids) > @reorder_entities_max_uuids do
log_entity_reorder_rejected(:too_many_uuids, length(ordered_uuids), opts)
{:error, :too_many_uuids}
end
def reorder_entities(ordered_uuids, opts) when is_list(ordered_uuids) do
# Dedup defensively (last occurrence wins, same shape as
# `EntityData.bulk_update_positions/2`).
unique_uuids =
ordered_uuids
|> Enum.reverse()
|> Enum.uniq()
|> Enum.reverse()
case write_entity_positions(unique_uuids) do
{:ok, _} ->
# Audit-log the reorder. Metadata stays PII-safe — count + the
# first uuid are enough to attribute the action and locate it
# in the table; the full ordered list would bloat the row and
# add no diagnostic value beyond what `Entities.list_entities/0`
# at the same instant already shows.
log_entity_reorder_activity(unique_uuids, opts)
# Reuse the existing `:entity_updated` event — it's already
# wired to invalidate the Dashboard sidebar cache and refresh
# any open Entities/EntitiesSettings/DataNavigator LVs that
# render entity-keyed lists. Broadcasting once with the first
# uuid is sufficient since every listener re-fetches the full
# list on receipt.
broadcast_first_entity_updated(unique_uuids)
:ok
{:error, reason} ->
# Cover the user-initiated action even when the DB write fails.
# `db_pending: true` lets log consumers distinguish from
# successful rows; the actor still gets attribution in the
# audit trail.
log_entity_reorder_activity_error(unique_uuids, reason, opts)
{:error, reason}
end
end
defp write_entity_positions(unique_uuids) do
pairs = Enum.with_index(unique_uuids, 1)
now = UtilsDate.utc_now()
repo().transaction(fn ->
Enum.each(pairs, fn {uuid, idx} ->
from(e in __MODULE__, where: e.uuid == ^uuid)
|> repo().update_all(set: [position: idx, date_updated: now])
end)
end)
end
defp broadcast_first_entity_updated([uuid | _]) when is_binary(uuid),
do: Events.broadcast_entity_updated(uuid)
defp broadcast_first_entity_updated(_), do: :ok
defp log_entity_reorder_activity([], _opts), do: :ok
defp log_entity_reorder_activity([first_uuid | _rest] = uuids, opts) do
PhoenixKitEntities.ActivityLog.log(%{
action: "entity.reordered",
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: "entity",
resource_uuid: first_uuid,
metadata: %{"count" => length(uuids)}
})
end
defp log_entity_reorder_activity_error([], _reason, _opts), do: :ok
defp log_entity_reorder_activity_error([first_uuid | _rest] = uuids, _reason, opts) do
PhoenixKitEntities.ActivityLog.log(%{
action: "entity.reordered",
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: "entity",
resource_uuid: first_uuid,
metadata: %{"count" => length(uuids), "db_pending" => true}
})
end
defp log_entity_reorder_rejected(reason, count, opts) do
PhoenixKitEntities.ActivityLog.log(%{
action: "entity.reordered",
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: "entity",
metadata: %{
"count" => count,
"db_pending" => true,
"rejected" => to_string(reason)
}
})
end
@doc """
Gets summary statistics for the entities system.
Returns counts and metrics useful for admin dashboards.
## Examples
iex> PhoenixKitEntities.get_system_stats()
%{total_entities: 5, active_entities: 4, total_data_records: 150}
"""
@spec get_system_stats() :: map()
def get_system_stats do
entities_query = from(e in __MODULE__)
data_query = from(d in PhoenixKitEntities.EntityData)
total_entities = repo().aggregate(entities_query, :count)
active_entities =
repo().aggregate(from(e in entities_query, where: e.status == "published"), :count)
total_data_records = repo().aggregate(data_query, :count)
%{
total_entities: total_entities,
active_entities: active_entities,
total_data_records: total_data_records
}
end
@doc """
Counts the total number of entities created by a user.
## Examples
iex> PhoenixKitEntities.count_user_entities(1)
5
"""
@spec count_user_entities(String.t()) :: non_neg_integer()
def count_user_entities(user_uuid) when is_binary(user_uuid) do
from(e in __MODULE__, where: e.created_by_uuid == ^user_uuid, select: count(e.uuid))
|> repo().one()
end
@doc """
Counts the total number of entities in the system.
## Examples
iex> PhoenixKitEntities.count_entities()
15
"""
@spec count_entities() :: non_neg_integer()
def count_entities do
from(e in __MODULE__, select: count(e.uuid))
|> repo().one()
end
@doc """
Counts the total number of entity data records across all entities.
## Examples
iex> PhoenixKitEntities.count_all_entity_data()
243
"""
@spec count_all_entity_data() :: non_neg_integer()
def count_all_entity_data do
from(d in PhoenixKitEntities.EntityData, select: count(d.uuid))
|> repo().one()
end
@doc """
Validates that a user hasn't exceeded their entity creation limit.
Checks the current number of entities created by the user against the system limit.
Returns `{:ok, :valid}` if within limits, `{:error, reason}` if limit exceeded.
## Examples
iex> PhoenixKitEntities.validate_user_entity_limit(1)
{:ok, :valid}
iex> PhoenixKitEntities.validate_user_entity_limit(1)
{:error, {:user_entity_limit_reached, 100}}
Error tuples flow through `PhoenixKitEntities.Errors.message/1` for
user-facing strings.
"""
@spec validate_user_entity_limit(String.t()) ::
{:ok, :valid} | {:error, {:user_entity_limit_reached, non_neg_integer()}}
def validate_user_entity_limit(user_uuid) when is_binary(user_uuid) do
max_entities = get_max_per_user()
current_count = count_user_entities(user_uuid)
if current_count < max_entities do
{:ok, :valid}
else
{:error, {:user_entity_limit_reached, max_entities}}
end
end
@impl PhoenixKit.Module
@doc """
Checks if the entities system is enabled.
Returns true if the "entities_enabled" setting is true.
## Examples
iex> PhoenixKitEntities.enabled?()
false
"""
@spec enabled?() :: boolean()
def enabled? do
Settings.get_boolean_setting("entities_enabled", false)
rescue
_ -> false
catch
# Settings supervisor may exit during sandbox shutdown; treat as disabled.
:exit, _ -> false
end
@impl PhoenixKit.Module
@doc """
Enables the entities system.
Sets the "entities_enabled" setting to true and logs a
`module.entities.enabled` activity row.
## Options
* `:actor_uuid` — UUID of the user toggling the system. Threaded
through to the activity log entry. `nil` is allowed when the
caller doesn't have a scope (system jobs).
## Examples
iex> PhoenixKitEntities.enable_system(actor_uuid: admin.uuid)
{:ok, %Setting{}}
"""
@spec enable_system(keyword()) :: {:ok, term()} | {:error, term()}
def enable_system(opts \\ []) do
result = Settings.update_boolean_setting_with_module("entities_enabled", true, module_key())
log_module_toggle("module.entities.enabled", result, opts)
result
end
@impl PhoenixKit.Module
@doc """
Disables the entities system.
Sets the "entities_enabled" setting to false and logs a
`module.entities.disabled` activity row.
## Options
* `:actor_uuid` — see `enable_system/1`.
## Examples
iex> PhoenixKitEntities.disable_system(actor_uuid: admin.uuid)
{:ok, %Setting{}}
"""
@spec disable_system(keyword()) :: {:ok, term()} | {:error, term()}
def disable_system(opts \\ []) do
result = Settings.update_boolean_setting_with_module("entities_enabled", false, module_key())
log_module_toggle("module.entities.disabled", result, opts)
result
end
defp log_module_toggle(action, result, opts) do
metadata =
case result do
{:ok, _} -> %{"setting" => "entities_enabled"}
{:error, _} -> %{"setting" => "entities_enabled", "db_pending" => true}
end
PhoenixKitEntities.ActivityLog.log(%{
action: action,
mode: "manual",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: "module",
metadata: metadata
})
end
@doc """
Gets the maximum number of entities a single user can create.
Returns the system-wide limit for entity creation per user.
Defaults to 100 if not set.
## Examples
iex> PhoenixKitEntities.get_max_per_user()
100
"""
@spec get_max_per_user() :: non_neg_integer()
def get_max_per_user do
Settings.get_integer_setting("entities_max_per_user", 100)
end
@impl PhoenixKit.Module
@doc """
Gets the current entities system configuration.
Returns a map with the current settings.
## Examples
iex> PhoenixKitEntities.get_config()
%{enabled: false, max_per_user: 100, allow_relations: true, file_upload: false, entity_count: 0, total_data_count: 0}
Count queries are wrapped in `safe_count/1` so `get_config/0` works
outside of a sandbox checkout (e.g. unit-test contexts that don't
use `DataCase`) — same defensive pattern as `enabled?/0`.
"""
@spec get_config() :: map()
def get_config do
%{
enabled: enabled?(),
max_per_user: get_max_per_user(),
allow_relations: Settings.get_boolean_setting("entities_allow_relations", true),
file_upload: Settings.get_boolean_setting("entities_file_upload", false),
entity_count: safe_count(&count_entities/0),
total_data_count: safe_count(&count_all_entity_data/0)
}
end
defp safe_count(fun) when is_function(fun, 0) do
fun.()
rescue
_ -> 0
catch
:exit, _ -> 0
end
# ============================================================================
# Module Behaviour Callbacks
# ============================================================================
@impl PhoenixKit.Module
@spec module_key() :: String.t()
def module_key, do: "entities"
@impl PhoenixKit.Module
@spec module_name() :: String.t()
def module_name, do: "Entities"
@impl PhoenixKit.Module
@spec permission_metadata() :: map()
def permission_metadata do
%{
key: "entities",
label: "Entities",
icon: "hero-cube-transparent",
description: "Dynamic content types and custom data structures"
}
end
@impl PhoenixKit.Module
@spec admin_tabs() :: [PhoenixKit.Dashboard.Tab.t()]
def admin_tabs do
[
Tab.new!(
id: :admin_entities,
label: "Entities",
icon: "hero-cube",
path: "entities",
priority: 540,
level: :admin,
permission: "entities",
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
dynamic_children: &__MODULE__.entities_children/1
)
]
end
# ETS cache TTL for entity summaries (30 seconds)
@entities_cache_ttl_ms 30_000
@entities_cache_key :entities_children_cache
@doc """
Invalidates the cached entity summaries in the Dashboard Registry's ETS table.
Called when entity lifecycle PubSub events are received.
"""
@spec invalidate_entities_cache() :: :ok
def invalidate_entities_cache do
alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry
if DashboardRegistry.initialized?() do
# Delete all entries for this cache key across all locales
# Pattern matches: {{:entities_children_cache, _}, _, _}
:ets.match_delete(DashboardRegistry.ets_table(), {{@entities_cache_key, :_}, :_, :_})
end
:ok
end
@doc """
Dynamic children function for Entities sidebar tabs.
Supports both arities:
- `entities_children(scope, locale)` — preferred when phoenix_kit core
(>= pending `dynamic_children/2` release) passes the current locale
explicitly to the sidebar callback.
- `entities_children(scope)` — fallback that reads the locale from
`Gettext.get_locale/1`. Older core releases dispatch this form.
"""
@spec entities_children(any(), String.t() | nil) :: [PhoenixKit.Dashboard.Tab.t()]
@spec entities_children(any()) :: [PhoenixKit.Dashboard.Tab.t()]
def entities_children(_scope, locale) when is_binary(locale) or is_nil(locale) do
cached_entity_summaries(locale || Gettext.get_locale(PhoenixKitWeb.Gettext))
|> build_entity_tabs()
rescue
_ -> []
end
def entities_children(_scope) do
locale = Gettext.get_locale(PhoenixKitWeb.Gettext)
cached_entity_summaries(locale)
|> build_entity_tabs()
rescue
_ -> []
end
defp build_entity_tabs(summaries) do
summaries
|> Enum.with_index()
|> Enum.map(fn {entity, idx} ->
%Tab{
id:
String.to_atom(
"admin_entity_#{entity.name}_#{:erlang.phash2(entity.name) |> Integer.to_string(16) |> String.downcase()}"
),
label: entity.display_name_plural || entity.display_name,
icon: entity.icon || "hero-cube",
path: "entities/#{entity.name}/data",
priority: 541 + idx,
level: :admin,
permission: "entities",
match: :prefix,
parent: :admin_entities
}
end)
end
defp cached_entity_summaries(locale) do
alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry
if DashboardRegistry.initialized?() do
lookup_cached_entities(DashboardRegistry, locale)
else
list_entity_summaries(lang: locale)
end
end
defp lookup_cached_entities(registry, locale) do
cache_key = {@entities_cache_key, locale}
case :ets.lookup(registry.ets_table(), cache_key) do
[{^cache_key, entities, timestamp}] when is_integer(timestamp) ->
if System.monotonic_time(:millisecond) - timestamp < @entities_cache_ttl_ms,
do: entities,
else: fetch_and_cache_entities(locale)
_ ->
fetch_and_cache_entities(locale)
end
end
defp fetch_and_cache_entities(locale) do
alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry
entities = list_entity_summaries(lang: locale)
if DashboardRegistry.initialized?() do
:ets.insert(
DashboardRegistry.ets_table(),
{{@entities_cache_key, locale}, entities, System.monotonic_time(:millisecond)}
)
end
entities
end
@impl PhoenixKit.Module
@spec settings_tabs() :: [PhoenixKit.Dashboard.Tab.t()]
def settings_tabs do
[
Tab.new!(
id: :admin_settings_entities,
label: "Entities",
icon: "hero-cube",
path: "entities",
priority: 935,
level: :admin,
parent: :admin_settings,
permission: "entities",
match: :prefix
)
]
end
@impl PhoenixKit.Module
@spec children() :: [module()]
def children, do: [PhoenixKitEntities.Presence]
@spec css_sources() :: [atom()]
def css_sources, do: [:phoenix_kit_entities]
@impl PhoenixKit.Module
@spec version() :: String.t()
def version, do: "0.1.4"
@impl PhoenixKit.Module
@spec route_module() :: module()
def route_module, do: PhoenixKitEntities.Routes
@impl PhoenixKit.Module
@spec sitemap_sources() :: [module()]
def sitemap_sources, do: [PhoenixKitEntities.SitemapSource]
# ============================================================================
# Sort Mode Settings
# ============================================================================
@valid_sort_modes ~w(auto manual)
@doc """
Gets the sort mode for an entity.
Returns `"auto"` (sort by creation date, default) or `"manual"` (sort by position).
## Examples
iex> PhoenixKitEntities.get_sort_mode(entity)
"auto"
"""
@spec get_sort_mode(t()) :: String.t()
def get_sort_mode(%__MODULE__{settings: settings}) do
(settings || %{}) |> Map.get("sort_mode", "auto")
end
@doc """
Gets the sort mode for an entity by UUID.
Convenience wrapper that looks up the entity first.
Returns `"auto"` if the entity is not found.
## Examples
iex> PhoenixKitEntities.get_sort_mode_by_uuid(entity_uuid)
"manual"
"""
@spec get_sort_mode_by_uuid(binary()) :: String.t()
def get_sort_mode_by_uuid(entity_uuid) when is_binary(entity_uuid) do
case get_entity(entity_uuid) do
nil -> "auto"
entity -> get_sort_mode(entity)
end
end
@doc """
Checks if an entity uses manual sorting.
## Examples
iex> PhoenixKitEntities.manual_sort?(entity)
true
"""
@spec manual_sort?(t()) :: boolean()
def manual_sort?(%__MODULE__{} = entity), do: get_sort_mode(entity) == "manual"
@doc """
Updates the sort mode for an entity.
Valid modes: `"auto"` (sort by creation date) or `"manual"` (sort by position).
When switching to manual mode, existing records retain their auto-populated
positions from creation order. Admins can then reorder as needed.
## Examples
iex> PhoenixKitEntities.update_sort_mode(entity, "manual")
{:ok, %PhoenixKitEntities{}}
"""
@spec update_sort_mode(t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_sort_mode(%__MODULE__{} = entity, mode) when mode in @valid_sort_modes do
current_settings = entity.settings || %{}
new_settings = Map.put(current_settings, "sort_mode", mode)
update_entity(entity, %{settings: new_settings})
end
# ============================================================================
# Per-Entity Mirror Settings
# ============================================================================
@doc """
Gets the mirror settings for an entity.
Returns a map with mirror_definitions and mirror_data booleans.
Defaults to false if not explicitly set.
## Examples
iex> PhoenixKitEntities.get_mirror_settings(entity)
%{mirror_definitions: true, mirror_data: false}
"""
@spec get_mirror_settings(t()) :: %{mirror_definitions: boolean(), mirror_data: boolean()}
def get_mirror_settings(%__MODULE__{settings: settings}) do
settings = settings || %{}
%{
mirror_definitions: Map.get(settings, "mirror_definitions", false),
mirror_data: Map.get(settings, "mirror_data", false)
}
end
@doc """
Checks if definition mirroring is enabled for this entity.
## Examples
iex> PhoenixKitEntities.mirror_definitions_enabled?(entity)
true
"""
@spec mirror_definitions_enabled?(t()) :: boolean()
def mirror_definitions_enabled?(%__MODULE__{settings: settings}) do
settings = settings || %{}
Map.get(settings, "mirror_definitions", false) == true
end
@doc """
Checks if data mirroring is enabled for this entity.
## Examples
iex> PhoenixKitEntities.mirror_data_enabled?(entity)
false
"""
@spec mirror_data_enabled?(t()) :: boolean()
def mirror_data_enabled?(%__MODULE__{settings: settings}) do
settings = settings || %{}
Map.get(settings, "mirror_data", false) == true
end
@doc """
Updates the mirror settings for an entity.
## Parameters
- `entity` - The entity to update
- `mirror_settings` - Map with keys "mirror_definitions" and/or "mirror_data"
## Examples
iex> PhoenixKitEntities.update_mirror_settings(entity, %{"mirror_definitions" => true})
{:ok, %PhoenixKit.Entities{}}
"""
@spec update_mirror_settings(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update_mirror_settings(%__MODULE__{} = entity, mirror_settings)
when is_map(mirror_settings) do
current_settings = entity.settings || %{}
new_settings = Map.merge(current_settings, mirror_settings)
update_entity(entity, %{settings: new_settings})
end
# ============================================================================
@doc """
Lists all entities with their mirror status and data counts.
Returns a list of maps suitable for the settings UI.
## Examples
iex> PhoenixKitEntities.list_entities_with_mirror_status()
[%{id: 1, name: "test", display_name: "Test", data_count: 8, mirror_definitions: true, mirror_data: false}, ...]
"""
@spec list_entities_with_mirror_status() :: [map()]
def list_entities_with_mirror_status do
entities = list_entities()
Enum.map(entities, fn entity ->
mirror_settings = get_mirror_settings(entity)
data_count = EntityData.count_by_entity(entity.uuid)
file_exists = Storage.entity_exists?(entity.name)
%{
uuid: entity.uuid,
name: entity.name,
display_name: entity.display_name,
data_count: data_count,
mirror_definitions: mirror_settings.mirror_definitions,
mirror_data: mirror_settings.mirror_data,
file_exists: file_exists
}
end)
end
@doc """
Enables definition mirroring for all entities.
## Examples
iex> PhoenixKitEntities.enable_all_definitions_mirror()
{:ok, count}
"""
@spec enable_all_definitions_mirror() :: {:ok, non_neg_integer()}
def enable_all_definitions_mirror do
entities = list_entities()
results =
Enum.map(entities, fn entity ->
update_mirror_settings(entity, %{"mirror_definitions" => true})
end)
success_count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, success_count}
end
@doc """
Disables definition mirroring for all entities.
## Examples
iex> PhoenixKitEntities.disable_all_definitions_mirror()
{:ok, count}
"""
@spec disable_all_definitions_mirror() :: {:ok, non_neg_integer()}
def disable_all_definitions_mirror do
entities = list_entities()
results =
Enum.map(entities, fn entity ->
update_mirror_settings(entity, %{"mirror_definitions" => false})
end)
success_count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, success_count}
end
@doc """
Enables data mirroring for all entities.
## Examples
iex> PhoenixKitEntities.enable_all_data_mirror()
{:ok, count}
"""
@spec enable_all_data_mirror() :: {:ok, non_neg_integer()}
def enable_all_data_mirror do
entities = list_entities()
results =
Enum.map(entities, fn entity ->
update_mirror_settings(entity, %{"mirror_data" => true})
end)
success_count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, success_count}
end
@doc """
Disables data mirroring for all entities.
## Examples
iex> PhoenixKitEntities.disable_all_data_mirror()
{:ok, count}
"""
@spec disable_all_data_mirror() :: {:ok, non_neg_integer()}
def disable_all_data_mirror do
entities = list_entities()
results =
Enum.map(entities, fn entity ->
update_mirror_settings(entity, %{"mirror_data" => false})
end)
success_count = Enum.count(results, &match?({:ok, _}, &1))
{:ok, success_count}
end
# ============================================================================
# Translation convenience API
# ============================================================================
@doc """
Gets all translations for an entity definition.
Returns a map of language codes to translated fields.
Only includes languages that have at least one translated field.
## Examples
iex> get_entity_translations(entity)
%{
"es-ES" => %{"display_name" => "Productos", "display_name_plural" => "Productos"},
"fr-FR" => %{"display_name" => "Produits"}
}
iex> get_entity_translations(entity_without_translations)
%{}
"""
@spec get_entity_translations(t()) :: %{optional(String.t()) => map()}
def get_entity_translations(%__MODULE__{settings: settings}) do
(settings || %{})
|> Map.get("translations", %{})
end
@doc """
Gets the translation for a specific language on an entity definition.
Returns the translated fields merged with the primary language values
as defaults. Returns primary language values if no translation exists.
## Examples
iex> get_entity_translation(entity, "es-ES")
%{"display_name" => "Productos", "display_name_plural" => "Productos", "description" => "..."}
"""
@spec get_entity_translation(t(), String.t()) :: map() | nil
def get_entity_translation(%__MODULE__{} = entity, lang_code) when is_binary(lang_code) do
primary = %{
"display_name" => entity.display_name,
"display_name_plural" => entity.display_name_plural,
"description" => entity.description
}
translations = get_entity_translations(entity)
lang_overrides = lookup_translation(translations, lang_code)
Map.merge(primary, lang_overrides)
end
@doc """
Sets the translation for a specific language on an entity definition.
Merges the provided fields into the existing translation for that language.
Empty string values are treated as "remove override" (field falls back to primary).
## Examples
iex> set_entity_translation(entity, "es-ES", %{
...> "display_name" => "Productos",
...> "display_name_plural" => "Productos"
...> })
{:ok, %PhoenixKitEntities{}}
"""
@spec set_entity_translation(t(), String.t(), map()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def set_entity_translation(%__MODULE__{} = entity, lang_code, attrs)
when is_binary(lang_code) and is_map(attrs) do
current_settings = entity.settings || %{}
translations = Map.get(current_settings, "translations", %{})
existing = Map.get(translations, lang_code, %{})
merged = Map.merge(existing, attrs)
# Remove empty values (fall back to primary)
cleaned =
merged
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> Map.new()
updated_translations =
if map_size(cleaned) == 0 do
Map.delete(translations, lang_code)
else
Map.put(translations, lang_code, cleaned)
end
new_settings =
if map_size(updated_translations) == 0 do
Map.delete(current_settings, "translations")
else
Map.put(current_settings, "translations", updated_translations)
end
update_entity(entity, %{settings: new_settings})
end
@doc """
Removes all translations for a specific language from an entity definition.
## Examples
iex> remove_entity_translation(entity, "es-ES")
{:ok, %PhoenixKitEntities{}}
"""
@spec remove_entity_translation(t(), String.t()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def remove_entity_translation(%__MODULE__{} = entity, lang_code)
when is_binary(lang_code) do
current_settings = entity.settings || %{}
translations = Map.get(current_settings, "translations", %{})
updated = Map.delete(translations, lang_code)
new_settings =
if map_size(updated) == 0 do
Map.delete(current_settings, "translations")
else
Map.put(current_settings, "translations", updated)
end
update_entity(entity, %{settings: new_settings})
end
@doc """
Checks if multilang is globally enabled (Languages module has 2+ languages).
Convenience wrapper around `Multilang.enabled?/0`.
## Examples
iex> PhoenixKitEntities.multilang_enabled?()
true
"""
@spec multilang_enabled?() :: boolean()
def multilang_enabled?, do: Multilang.enabled?()
# ============================================================================
# Language-aware API
# ============================================================================
@doc """
Resolves translated fields on an entity struct for a given language.
Merges translations from `settings["translations"][lang_code]` onto the
entity's `display_name`, `display_name_plural`, and `description` fields.
For the primary language (or when no translation exists), returns the entity
unchanged. For secondary languages, applies override values where they exist
and keeps primary values as defaults.
## Examples
iex> resolve_language(entity, "es-ES")
%PhoenixKitEntities{display_name: "Productos", ...}
iex> resolve_language(entity, "en-US") # primary language
%PhoenixKitEntities{display_name: "Products", ...}
"""
@spec resolve_language(t(), String.t() | nil) :: t()
def resolve_language(entity, nil), do: entity
@spec resolve_language(t(), String.t()) :: t()
def resolve_language(%__MODULE__{} = entity, lang_code) when is_binary(lang_code) do
translation = get_entity_translation(entity, lang_code)
entity
|> maybe_apply_translation(:display_name, translation["display_name"])
|> maybe_apply_translation(:display_name_plural, translation["display_name_plural"])
|> maybe_apply_translation(:description, translation["description"])
end
defp maybe_apply_translation(entity, _field, nil), do: entity
defp maybe_apply_translation(entity, _field, ""), do: entity
defp maybe_apply_translation(entity, field, value) do
Map.put(entity, field, value)
end
@doc """
Resolves translations on a list of entity structs.
## Examples
iex> resolve_languages(entities, "es-ES")
[%PhoenixKitEntities{display_name: "Productos"}, ...]
"""
@spec resolve_languages([t()], String.t() | nil) :: [t()]
def resolve_languages(entities, nil), do: entities
@spec resolve_languages([t()], String.t()) :: [t()]
def resolve_languages(entities, lang_code) when is_list(entities) and is_binary(lang_code) do
Enum.map(entities, &resolve_language(&1, lang_code))
end
@doc false
@spec maybe_resolve_lang(t(), keyword()) :: t()
def maybe_resolve_lang(entity, opts) when is_list(opts) do
case Keyword.get(opts, :lang) do
nil -> entity
lang -> resolve_language(entity, lang)
end
end
# Applies :lang option to a list of entities if present in opts
defp maybe_resolve_langs(entities, opts) when is_list(entities) and is_list(opts) do
case Keyword.get(opts, :lang) do
nil -> entities
lang -> resolve_languages(entities, lang)
end
end
defp repo do
PhoenixKit.RepoHelper.repo()
end
end