berrypod/lib/berrypod/settings.ex
jamey 64f083d271
All checks were successful
deploy / deploy (push) Successful in 1m31s
improve setup UX: password field, setup hook, checklist banners, theme tweaks
- add password field and required shop name to setup wizard
- extract SetupHook for DRY redirect to /setup when no admin exists
- add ?from=checklist param to checklist hrefs with contextual banner on
  email settings and theme pages for easy return to dashboard
- remove email warning banner from admin layout (checklist covers it)
- make email a required checklist item (no longer optional)
- add DevReset module for wiping dev data without restart
- rename "Theme Studio" to "Theme", drop subtitle
- lower theme editor side-by-side breakpoint from 64em to 48em
- clean up login/registration pages (remove dead registration_open code)
- fix settings.put_secret to invalidate cache after write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:41:08 +00:00

321 lines
7.8 KiB
Elixir

defmodule Berrypod.Settings do
@moduledoc """
The Settings context for managing site-wide configuration.
"""
import Ecto.Query, warn: false
alias Berrypod.Repo
alias Berrypod.Settings.{Setting, SettingsCache, ThemeSettings}
alias Berrypod.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 SettingsCache.get(key) do
{:ok, value} ->
value
:miss ->
case fetch_setting(key) do
{:ok, setting} -> decode_value(setting)
:not_found -> default
end
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)
result =
%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
)
case result do
{:ok, _} ->
SettingsCache.invalidate()
SettingsCache.warm()
_ ->
:ok
end
result
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 ->
%ThemeSettings{}
settings_map when is_map(settings_map) ->
settings_map
|> atomize_keys()
|> then(&struct(ThemeSettings, &1))
end
end
@doc "Returns the shop name from Settings, falling back to a default."
def site_name do
get_setting("site_name") || "Store Name"
end
@doc "Returns the shop description from Settings, falling back to a default."
def site_description do
get_setting("site_description") || "Discover unique products and original designs."
end
@doc """
Updates the theme settings.
## Examples
iex> update_theme_settings(%{mood: "dark", typography: "modern"})
{:ok, %ThemeSettings{}}
"""
def update_theme_settings(attrs, opts \\ []) 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")
unless opts[:skip_customised_flag] do
put_setting("theme_customised", true, "boolean")
end
# Invalidate and rewarm CSS cache
alias Berrypod.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, opts \\ []) when is_atom(preset_name) do
preset = Berrypod.Theme.Presets.get(preset_name)
if preset do
update_theme_settings(preset, opts)
else
{:error, :preset_not_found}
end
end
@doc """
Returns whether the shop is live (visible to the public).
Defaults to `false` for fresh installs.
"""
def site_live? do
get_setting("site_live", false) == true
end
@doc """
Sets whether the shop is live (visible to the public).
"""
def set_site_live(live?) when is_boolean(live?) do
put_setting("site_live", live?, "boolean")
end
@doc """
Returns whether abandoned cart recovery emails are enabled.
Defaults to false — shop owners must explicitly opt in, since the
feature has legal implications (privacy policy wording, PECR/GDPR compliance).
"""
def abandoned_cart_recovery_enabled? do
get_setting("abandoned_cart_recovery", false) == true
end
@doc """
Enables or disables abandoned cart recovery emails.
"""
def set_abandoned_cart_recovery(enabled?) when is_boolean(enabled?) do
put_setting("abandoned_cart_recovery", enabled?, "boolean")
end
@doc """
Deletes a setting by key.
"""
def delete_setting(key) do
result =
case fetch_setting(key) do
{:ok, setting} -> Repo.delete(setting)
:not_found -> :ok
end
SettingsCache.invalidate()
SettingsCache.warm()
result
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
result =
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
SettingsCache.invalidate()
SettingsCache.warm()
result
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