2025-12-30 21:35:52 +00:00
|
|
|
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}
|
2026-02-07 17:12:53 +00:00
|
|
|
alias SimpleshopTheme.Vault
|
2025-12-30 21:35:52 +00:00
|
|
|
|
|
|
|
|
@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 Repo.get_by(Setting, key: key) do
|
|
|
|
|
nil -> default
|
|
|
|
|
setting -> decode_value(setting)
|
|
|
|
|
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")
|
2026-01-17 16:19:35 +00:00
|
|
|
|
|
|
|
|
# Invalidate and rewarm CSS cache
|
|
|
|
|
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
|
|
|
|
|
CSSCache.invalidate()
|
|
|
|
|
css = CSSGenerator.generate(settings)
|
|
|
|
|
CSSCache.put(css)
|
|
|
|
|
|
2025-12-30 21:35:52 +00:00
|
|
|
{: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
|
|
|
|
|
|
2026-02-07 17:12:53 +00:00
|
|
|
@doc """
|
|
|
|
|
Deletes a setting by key.
|
|
|
|
|
"""
|
|
|
|
|
def delete_setting(key) do
|
|
|
|
|
case Repo.get_by(Setting, key: key) do
|
|
|
|
|
nil -> :ok
|
|
|
|
|
setting -> Repo.delete(setting)
|
|
|
|
|
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 Repo.get_by(Setting, key: key) do
|
|
|
|
|
%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 Repo.get_by(Setting, key: key) do
|
|
|
|
|
%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
|
|
|
|
|
|
2025-12-30 21:35:52 +00:00
|
|
|
# Private helpers
|
|
|
|
|
|
2026-02-07 17:12:53 +00:00
|
|
|
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
|
|
|
|
|
|
2025-12-30 21:35:52 +00:00
|
|
|
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
|