defmodule SimpleshopTheme.Settings do @moduledoc """ The Settings context for managing site-wide configuration. """ import Ecto.Query, warn: false alias SimpleshopTheme.Repo alias SimpleshopTheme.Settings.{Setting, ThemeSettings} alias SimpleshopTheme.Vault @doc """ Gets a setting by key with an optional default value. ## Examples iex> get_setting("site_name", "My Shop") "My Shop" """ def get_setting(key, default \\ nil) do case fetch_setting(key) do {:ok, setting} -> decode_value(setting) :not_found -> default end end @doc """ Sets a setting value by key. ## Examples iex> put_setting("site_name", "My Awesome Shop") {:ok, %Setting{}} """ def put_setting(key, value, value_type \\ "string") do encoded_value = encode_value(value, value_type) %Setting{key: key} |> Setting.changeset(%{key: key, value: encoded_value, value_type: value_type}) |> Repo.insert( on_conflict: {:replace, [:value, :value_type, :updated_at]}, conflict_target: :key ) end @doc """ Gets the theme settings as a ThemeSettings struct. ## Examples iex> get_theme_settings() %ThemeSettings{mood: "neutral", typography: "clean", ...} """ def get_theme_settings do case get_setting("theme_settings") do nil -> # Return defaults %ThemeSettings{} settings_map when is_map(settings_map) -> settings_map |> atomize_keys() |> then(&struct(ThemeSettings, &1)) end end @doc """ Updates the theme settings. ## Examples iex> update_theme_settings(%{mood: "dark", typography: "modern"}) {:ok, %ThemeSettings{}} """ def update_theme_settings(attrs) when is_map(attrs) do current = get_theme_settings() changeset = ThemeSettings.changeset(current, attrs) if changeset.valid? do settings = Ecto.Changeset.apply_changes(changeset) json = Jason.encode!(settings) put_setting("theme_settings", json, "json") # Invalidate and rewarm CSS cache alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator} CSSCache.invalidate() css = CSSGenerator.generate(settings) CSSCache.put(css) {:ok, settings} else {:error, changeset} end end @doc """ Applies a preset to theme settings. ## Examples iex> apply_preset(:gallery) {:ok, %ThemeSettings{}} """ def apply_preset(preset_name) when is_atom(preset_name) do preset = SimpleshopTheme.Theme.Presets.get(preset_name) if preset do update_theme_settings(preset) else {:error, :preset_not_found} end end @doc """ Deletes a setting by key. """ def delete_setting(key) do case fetch_setting(key) do {:ok, setting} -> Repo.delete(setting) :not_found -> :ok end end @doc """ Stores an encrypted secret in the database. The plaintext is encrypted via Vault before storage. """ def put_secret(key, plaintext) when is_binary(plaintext) do case Vault.encrypt(plaintext) do {:ok, encrypted} -> %Setting{key: key} |> Setting.changeset(%{ key: key, value: "[encrypted]", value_type: "encrypted", encrypted_value: encrypted }) |> Repo.insert( on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]}, conflict_target: :key ) {:error, reason} -> {:error, reason} end end @doc """ Retrieves and decrypts an encrypted secret from the database. Returns the plaintext value or the default if not found. """ def get_secret(key, default \\ nil) do case fetch_setting(key) do {:ok, %Setting{value_type: "encrypted", encrypted_value: encrypted}} when not is_nil(encrypted) -> case Vault.decrypt(encrypted) do {:ok, plaintext} -> plaintext {:error, _} -> default end _ -> default end end @doc """ Checks whether an encrypted secret exists in the database. """ def has_secret?(key) do case fetch_setting(key) do {:ok, %Setting{value_type: "encrypted", encrypted_value: encrypted}} when not is_nil(encrypted) -> true _ -> false end end @doc """ Returns a masked hint for an encrypted secret (e.g. "sk_test_•••abc"). Useful for admin UIs to confirm which key is active without exposing it. """ def secret_hint(key) do case get_secret(key) do nil -> nil plaintext when byte_size(plaintext) > 8 -> prefix = binary_part(plaintext, 0, min(8, byte_size(plaintext))) suffix = binary_part(plaintext, byte_size(plaintext), -3) "#{prefix}•••#{suffix}" _short -> "•••" end end # Private helpers defp fetch_setting(key) do case Repo.get_by(Setting, key: key) do nil -> :not_found setting -> {:ok, setting} end end defp decode_value(%Setting{value_type: "encrypted", encrypted_value: encrypted}) when not is_nil(encrypted) do case Vault.decrypt(encrypted) do {:ok, plaintext} -> plaintext {:error, _} -> nil end end defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value) defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value) defp decode_value(%Setting{value: value, value_type: "boolean"}), do: value == "true" defp decode_value(%Setting{value: value, value_type: "string"}), do: value defp encode_value(value, "json") when is_binary(value), do: value defp encode_value(value, "json"), do: Jason.encode!(value) defp encode_value(value, "integer") when is_integer(value), do: Integer.to_string(value) defp encode_value(value, "boolean") when is_boolean(value), do: Atom.to_string(value) defp encode_value(value, "string") when is_binary(value), do: value defp atomize_keys(map) when is_map(map) do Map.new(map, fn {key, value} when is_binary(key) -> {String.to_atom(key), value} {key, value} -> {key, value} end) end end