defmodule PhoenixKit.Modules.Entities.Web.EntitiesSettings do @moduledoc """ LiveView for managing entities system settings and configuration. Provides interface for enabling/disabling entities module and viewing statistics. """ use PhoenixKitWeb, :live_view on_mount PhoenixKit.Modules.Entities.Web.Hooks alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.EntityData alias PhoenixKit.Modules.Entities.Events alias PhoenixKit.Modules.Entities.Mirror.{Exporter, Importer, Storage} alias PhoenixKit.Settings def mount(_params, _session, socket) do # Set locale for LiveView process project_title = Settings.get_setting("project_title", "PhoenixKit") # Load current entities settings settings = %{ entities_enabled: Entities.enabled?(), auto_generate_slugs: Settings.get_setting("entities_auto_generate_slugs", "true"), default_status: Settings.get_setting("entities_default_status", "draft"), require_approval: Settings.get_setting("entities_require_approval", "false"), max_entities_per_user: Settings.get_setting("entities_max_per_user", "100"), data_retention_days: Settings.get_setting("entities_data_retention_days", "365"), enable_revisions: Settings.get_setting("entities_enable_revisions", "false"), enable_comments: Settings.get_setting("entities_enable_comments", "false") } changeset = build_changeset(settings) socket = socket |> assign(:page_title, gettext("Entities Settings")) |> assign(:project_title, project_title) |> assign(:settings, settings) |> assign(:changeset, changeset) |> assign(:entities_stats, get_entities_stats()) # Per-entity mirror settings |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> assign(:mirror_path, Storage.root_path()) |> assign(:export_stats, Storage.get_stats()) |> assign(:import_preview, nil) |> assign(:import_selections, %{}) |> assign(:import_active_tab, nil) |> assign(:show_import_modal, false) |> assign(:importing, false) |> assign(:exporting, false) if connected?(socket) do Events.subscribe_to_all_data() end {:ok, socket} end def handle_event("validate", %{"settings" => settings_params}, socket) do changeset = build_changeset(settings_params, :validate) {:noreply, assign(socket, :changeset, changeset)} end def handle_event("save", %{"settings" => settings_params}, socket) do changeset = build_changeset(settings_params, :save) if changeset.valid? do case save_settings(settings_params) do :ok -> # Refresh settings and stats new_settings = %{ entities_enabled: Entities.enabled?(), auto_generate_slugs: Settings.get_setting("entities_auto_generate_slugs", "true"), default_status: Settings.get_setting("entities_default_status", "draft"), require_approval: Settings.get_setting("entities_require_approval", "false"), max_entities_per_user: Settings.get_setting("entities_max_per_user", "100"), data_retention_days: Settings.get_setting("entities_data_retention_days", "365"), enable_revisions: Settings.get_setting("entities_enable_revisions", "false"), enable_comments: Settings.get_setting("entities_enable_comments", "false") } socket = socket |> assign(:settings, new_settings) |> assign(:changeset, build_changeset(new_settings)) |> assign(:entities_stats, get_entities_stats()) |> put_flash(:info, gettext("Entities settings saved successfully")) {:noreply, socket} {:error, reason} -> socket = put_flash( socket, :error, gettext("Failed to save settings: %{reason}", reason: reason) ) {:noreply, socket} end else {:noreply, assign(socket, :changeset, changeset)} end end def handle_event("enable_entities", _params, socket) do case Entities.enable_system() do {:ok, _setting} -> settings = Map.put(socket.assigns.settings, :entities_enabled, true) socket = socket |> assign(:settings, settings) |> assign(:changeset, build_changeset(settings)) |> assign(:entities_stats, get_entities_stats()) |> put_flash(:info, gettext("Entities system enabled successfully")) {:noreply, socket} {:error, reason} -> socket = put_flash( socket, :error, gettext("Failed to enable entities: %{reason}", reason: reason) ) {:noreply, socket} end end def handle_event("disable_entities", _params, socket) do case Entities.disable_system() do {:ok, _setting} -> settings = Map.put(socket.assigns.settings, :entities_enabled, false) socket = socket |> assign(:settings, settings) |> assign(:changeset, build_changeset(settings)) |> assign(:entities_stats, get_entities_stats()) |> put_flash(:info, gettext("Entities system disabled successfully")) {:noreply, socket} {:error, reason} -> socket = put_flash( socket, :error, gettext("Failed to disable entities: %{reason}", reason: reason) ) {:noreply, socket} end end def handle_event("reset_to_defaults", _params, socket) do default_settings = %{ entities_enabled: true, auto_generate_slugs: "true", default_status: "draft", require_approval: "false", max_entities_per_user: "unlimited", data_retention_days: "365", enable_revisions: "false", enable_comments: "false" } changeset = build_changeset(default_settings) socket = socket |> assign(:settings, default_settings) |> assign(:changeset, changeset) |> put_flash(:info, gettext("Settings reset to defaults (not saved yet)")) {:noreply, socket} end ## Per-Entity Mirror Events def handle_event("toggle_entity_definitions", %{"id" => entity_id}, socket) do entity_id = String.to_integer(entity_id) with {:ok, entity} <- fetch_entity(entity_id), {:ok, updated_entity} <- toggle_definitions_setting(entity) do maybe_export_entity(updated_entity, Entities.mirror_definitions_enabled?(updated_entity)) {:noreply, refresh_entities_list(socket)} else {:error, :not_found} -> {:noreply, put_flash(socket, :error, gettext("Entity not found"))} {:error, _changeset} -> {:noreply, put_flash(socket, :error, gettext("Failed to update mirror settings"))} end end def handle_event("toggle_entity_data", %{"id" => entity_id}, socket) do entity_id = String.to_integer(entity_id) with {:ok, entity} <- fetch_entity(entity_id), {:ok, updated_entity} <- toggle_data_setting(entity) do maybe_export_entity(updated_entity, Entities.mirror_data_enabled?(updated_entity)) {:noreply, refresh_entities_list(socket)} else {:error, :not_found} -> {:noreply, put_flash(socket, :error, gettext("Entity not found"))} {:error, _changeset} -> {:noreply, put_flash(socket, :error, gettext("Failed to update mirror settings"))} end end def handle_event("export_entity_now", %{"id" => entity_id}, socket) do entity_id = String.to_integer(entity_id) case Entities.get_entity(entity_id) do nil -> {:noreply, put_flash(socket, :error, gettext("Entity not found"))} entity -> message = case Exporter.export_entity(entity) do {:ok, _path, :with_data} -> gettext("Exported %{name} (definition + records)", name: entity.display_name) {:ok, _path, :definition_only} -> gettext("Exported %{name} (definition only)", name: entity.display_name) {:error, _reason} -> nil end socket = socket |> assign(:export_stats, Storage.get_stats()) |> then(fn s -> if message, do: put_flash(s, :info, message), else: put_flash(s, :error, gettext("Export failed")) end) {:noreply, socket} end end ## Bulk Mirror Actions def handle_event("enable_all_definitions", _params, socket) do {:ok, count} = Entities.enable_all_definitions_mirror() # Export all entities socket = assign(socket, :exporting, true) send(self(), :do_full_export) socket = socket |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> put_flash(:info, gettext("Enabled definition sync for %{count} entities", count: count)) {:noreply, socket} end def handle_event("disable_all_definitions", _params, socket) do # Disabling definitions also disables data {:ok, _} = Entities.disable_all_data_mirror() {:ok, count} = Entities.disable_all_definitions_mirror() socket = socket |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> put_flash(:info, gettext("Disabled definition sync for %{count} entities", count: count)) {:noreply, socket} end def handle_event("enable_all_data", _params, socket) do # Enabling data also requires definitions to be enabled {:ok, _} = Entities.enable_all_definitions_mirror() {:ok, count} = Entities.enable_all_data_mirror() # Export all entities with data socket = assign(socket, :exporting, true) send(self(), :do_full_export) socket = socket |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> put_flash(:info, gettext("Enabled data sync for %{count} entities", count: count)) {:noreply, socket} end def handle_event("disable_all_data", _params, socket) do {:ok, count} = Entities.disable_all_data_mirror() socket = socket |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> put_flash(:info, gettext("Disabled data sync for %{count} entities", count: count)) {:noreply, socket} end def handle_event("export_now", _params, socket) do socket = assign(socket, :exporting, true) send(self(), :do_full_export) {:noreply, socket} end def handle_event("show_import_modal", _params, socket) do preview = Importer.preview_import() # Initialize selections based on preview - default to appropriate action selections = build_default_selections(preview) first_entity = List.first(preview.entities) socket = socket |> assign(:import_preview, preview) |> assign(:import_selections, selections) |> assign(:import_active_tab, first_entity && first_entity.name) |> assign(:show_import_modal, true) {:noreply, socket} end def handle_event("hide_import_modal", _params, socket) do socket = socket |> assign(:show_import_modal, false) |> assign(:import_preview, nil) |> assign(:import_selections, %{}) |> assign(:import_active_tab, nil) {:noreply, socket} end def handle_event("set_import_tab", %{"entity" => entity_name}, socket) do {:noreply, assign(socket, :import_active_tab, entity_name)} end def handle_event( "set_definition_action", %{"entity" => entity_name, "action" => action}, socket ) do action_atom = String.to_existing_atom(action) selections = put_in(socket.assigns.import_selections, [entity_name, :definition], action_atom) {:noreply, assign(socket, :import_selections, selections)} end def handle_event( "set_record_action", %{"entity" => entity_name, "slug" => slug, "action" => action}, socket ) do action_atom = String.to_existing_atom(action) selections = put_in(socket.assigns.import_selections, [entity_name, :data, slug], action_atom) {:noreply, assign(socket, :import_selections, selections)} end def handle_event( "set_all_records_action", %{"entity" => entity_name, "action" => action}, socket ) do action_atom = String.to_existing_atom(action) # Find the entity in preview to get all slugs entity = Enum.find(socket.assigns.import_preview.entities, &(&1.name == entity_name)) if entity do new_data_selections = entity.data |> Enum.map(fn record -> {record.slug, action_atom} end) |> Map.new() selections = put_in(socket.assigns.import_selections, [entity_name, :data], new_data_selections) {:noreply, assign(socket, :import_selections, selections)} else {:noreply, socket} end end def handle_event("do_import_entity", %{"entity" => entity_name}, socket) do # Only import selections for the specified entity entity_selections = Map.get(socket.assigns.import_selections, entity_name, %{}) filtered_selections = %{entity_name => entity_selections} socket = socket |> assign(:importing, true) |> assign(:show_import_modal, false) send(self(), {:do_import, filtered_selections}) {:noreply, socket} end def handle_event("do_import", _params, socket) do socket = socket |> assign(:importing, true) |> assign(:show_import_modal, false) send(self(), {:do_import, socket.assigns.import_selections}) {:noreply, socket} end def handle_event("refresh_export_stats", _params, socket) do socket = socket |> assign(:export_stats, Storage.get_stats()) {:noreply, socket} end ## Per-entity mirror helpers defp fetch_entity(entity_id) do case Entities.get_entity(entity_id) do nil -> {:error, :not_found} entity -> {:ok, entity} end end defp toggle_definitions_setting(entity) do new_value = !Entities.mirror_definitions_enabled?(entity) new_settings = if new_value, do: %{"mirror_definitions" => true}, else: %{"mirror_definitions" => false, "mirror_data" => false} Entities.update_mirror_settings(entity, new_settings) end defp toggle_data_setting(entity) do new_value = !Entities.mirror_data_enabled?(entity) Entities.update_mirror_settings(entity, %{"mirror_data" => new_value}) end defp maybe_export_entity(entity, true), do: Task.start(fn -> Exporter.export_entity(entity) end) defp maybe_export_entity(_entity, false), do: :ok defp refresh_entities_list(socket) do socket |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> assign(:export_stats, Storage.get_stats()) end ## Live updates def handle_info({event, _entity_id}, socket) when event in [:entity_created, :entity_updated, :entity_deleted] do socket = socket |> assign(:entities_stats, get_entities_stats()) |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> assign(:export_stats, Storage.get_stats()) {:noreply, socket} end def handle_info({event, _entity_id, _data_id}, socket) when event in [:data_created, :data_updated, :data_deleted] do socket = socket |> assign(:entities_stats, get_entities_stats()) |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> assign(:export_stats, Storage.get_stats()) {:noreply, socket} end ## Mirror background operations def handle_info(:do_full_export, socket) do {:ok, %{definitions: def_count, data: data_count}} = Exporter.export_all() socket = socket |> assign(:exporting, false) |> assign(:export_stats, Storage.get_stats()) |> assign(:entities_list, Entities.list_entities_with_mirror_status()) |> put_flash( :info, gettext("Export complete. %{defs} definitions, %{data} records.", defs: def_count, data: data_count ) ) {:noreply, socket} end def handle_info({:do_import, selections}, socket) do {:ok, %{definitions: def_results, data: data_results}} = Importer.import_selected(selections) def_created = Enum.count(def_results, &match?({:ok, :created, _}, &1)) def_updated = Enum.count(def_results, &match?({:ok, :updated, _}, &1)) def_skipped = Enum.count(def_results, &match?({:ok, :skipped, _}, &1)) data_created = Enum.count(data_results, &match?({:ok, :created, _}, &1)) data_updated = Enum.count(data_results, &match?({:ok, :updated, _}, &1)) data_skipped = Enum.count(data_results, &match?({:ok, :skipped, _}, &1)) socket = socket |> assign(:importing, false) |> assign(:import_preview, nil) |> assign(:import_selections, %{}) |> assign(:export_stats, Storage.get_stats()) |> assign(:entities_stats, get_entities_stats()) |> put_flash( :info, gettext( "Import complete. Definitions: %{dc} created, %{du} updated, %{ds} skipped. Data: %{rc} created, %{ru} updated, %{rs} skipped.", dc: def_created, du: def_updated, ds: def_skipped, rc: data_created, ru: data_updated, rs: data_skipped ) ) {:noreply, socket} end # Private Functions defp build_changeset(settings, action \\ nil) do types = %{ entities_enabled: :boolean, auto_generate_slugs: :string, default_status: :string, require_approval: :string, max_entities_per_user: :string, data_retention_days: :string, enable_revisions: :string, enable_comments: :string } required = [:auto_generate_slugs, :default_status] changeset = {settings, types} |> Ecto.Changeset.cast(settings, Map.keys(types)) |> Ecto.Changeset.validate_required(required) |> Ecto.Changeset.validate_inclusion(:default_status, ["draft", "published", "archived"]) |> Ecto.Changeset.validate_inclusion(:auto_generate_slugs, ["true", "false"]) |> Ecto.Changeset.validate_inclusion(:require_approval, ["true", "false"]) |> Ecto.Changeset.validate_inclusion(:enable_revisions, ["true", "false"]) |> Ecto.Changeset.validate_inclusion(:enable_comments, ["true", "false"]) |> validate_max_entities_per_user() |> validate_data_retention_days() if action do Map.put(changeset, :action, action) else changeset end end defp validate_max_entities_per_user(changeset) do case Ecto.Changeset.get_field(changeset, :max_entities_per_user) do "unlimited" -> changeset value when is_binary(value) -> case Integer.parse(value) do {num, ""} when num > 0 -> changeset _ -> Ecto.Changeset.add_error( changeset, :max_entities_per_user, gettext("must be 'unlimited' or a positive integer") ) end _ -> Ecto.Changeset.add_error( changeset, :max_entities_per_user, gettext("must be 'unlimited' or a positive integer") ) end end defp validate_data_retention_days(changeset) do case Ecto.Changeset.get_field(changeset, :data_retention_days) do value when is_binary(value) -> case Integer.parse(value) do {num, ""} when num > 0 -> changeset _ -> Ecto.Changeset.add_error( changeset, :data_retention_days, gettext("must be a positive integer") ) end _ -> Ecto.Changeset.add_error( changeset, :data_retention_days, gettext("must be a positive integer") ) end end defp save_settings(settings_params) do settings_to_save = [ {"entities_auto_generate_slugs", Map.get(settings_params, "auto_generate_slugs", "true")}, {"entities_default_status", Map.get(settings_params, "default_status", "draft")}, {"entities_require_approval", Map.get(settings_params, "require_approval", "false")}, {"entities_max_per_user", Map.get(settings_params, "max_entities_per_user", "100")}, {"entities_data_retention_days", Map.get(settings_params, "data_retention_days", "365")}, {"entities_enable_revisions", Map.get(settings_params, "enable_revisions", "false")}, {"entities_enable_comments", Map.get(settings_params, "enable_comments", "false")} ] try do Enum.each(settings_to_save, fn {key, value} -> Settings.update_setting(key, value) end) :ok rescue e -> {:error, Exception.message(e)} end end defp get_entities_stats do if Entities.enabled?() do entities_stats = Entities.get_system_stats() data_stats = EntityData.get_data_stats() Map.merge(entities_stats, data_stats) else %{ total_entities: 0, active_entities: 0, total_data_records: 0, published_records: 0, draft_records: 0, archived_records: 0 } end end # Helper functions for templates def setting_status_class(enabled) do if enabled, do: "badge-success", else: "badge-error" end def setting_status_text(enabled) do if enabled, do: gettext("Enabled"), else: gettext("Disabled") end def format_retention_period(days) do case Integer.parse(days) do {num, ""} when num >= 365 -> years = div(num, 365) remainder = rem(num, 365) if remainder == 0 do ngettext("%{count} year", "%{count} years", years, count: years) else gettext("%{years} year(s), %{days} day(s)", years: years, days: remainder) end {num, ""} when num >= 30 -> months = div(num, 30) remainder = rem(num, 30) if remainder == 0 do ngettext("%{count} month", "%{count} months", months, count: months) else gettext("%{months} month(s), %{days} day(s)", months: months, days: remainder) end {num, ""} -> ngettext("%{count} day", "%{count} days", num, count: num) _ -> days end end # Build default import selections based on preview # - NEW items default to :overwrite (will create) # - IDENTICAL items default to :skip (nothing to do) # - CHANGED items default to :skip (safe default) defp build_default_selections(%{entities: entities}) do entities |> Enum.map(fn entity -> def_action = default_action_for(entity.definition.action) data_selections = entity.data |> Enum.map(fn record -> {record.slug, default_action_for(record.action)} end) |> Map.new() {entity.name, %{definition: def_action, data: data_selections}} end) |> Map.new() end defp default_action_for(:create), do: :overwrite defp default_action_for(:identical), do: :skip defp default_action_for(:conflict), do: :skip defp default_action_for(_), do: :skip # Helper to get current action for a record from selections def get_record_action(selections, entity_name, slug) do get_in(selections, [entity_name, :data, slug]) || :skip end def get_definition_action(selections, entity_name) do get_in(selections, [entity_name, :definition]) || :skip end end