Packages
phoenix_kit
1.7.76
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/publishing/groups.ex
defmodule PhoenixKit.Modules.Publishing.Groups do
@moduledoc """
Group management functions for the Publishing module.
Handles creating, listing, updating, and removing publishing groups,
as well as slug generation, type/mode normalization, and item naming.
"""
require Logger
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Shared
alias PhoenixKit.Modules.Publishing.StaleFixer
alias PhoenixKit.Modules.Publishing.Constants
@default_group_mode Constants.default_mode()
@default_group_type Constants.default_type()
@preset_types Constants.preset_types()
@valid_types Constants.valid_types()
@type_regex ~r/^[a-z][a-z0-9-]{0,31}$/
@type_item_names %{
"blog" => {"post", "posts"},
"faq" => {"question", "questions"},
"legal" => {"document", "documents"}
}
@default_item_singular "item"
@default_item_plural "items"
@type group :: map()
@doc """
Returns all publishing groups from the database.
"""
@spec list_groups() :: [group()]
def list_groups do
DBStorage.list_groups()
|> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end)
end
@doc "Lists groups filtered by status (e.g. 'active', 'trashed')."
@spec list_groups(String.t()) :: [group()]
def list_groups(status) do
DBStorage.list_groups(status)
|> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end)
end
@doc """
Gets a publishing group by slug.
## Examples
iex> Groups.get_group("news")
{:ok, %{"name" => "News", "slug" => "news", ...}}
iex> Groups.get_group("nonexistent")
{:error, :not_found}
"""
@spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found}
def get_group(slug) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil -> {:error, :not_found}
db_group -> {:ok, db_group |> StaleFixer.fix_stale_group() |> db_group_to_map()}
end
end
@doc """
Adds a new publishing group.
## Parameters
* `name` - Display name for the group
* `opts` - Keyword list or map with options:
* `:mode` - Post mode: "timestamp" or "slug" (default: "timestamp")
* `:slug` - Optional custom slug, auto-generated from name if nil
* `:type` - Content type: "blog", "faq", "legal", or custom (default: "blog")
* `:item_singular` - Singular name for items (default: based on type, e.g., "post")
* `:item_plural` - Plural name for items (default: based on type, e.g., "posts")
## Examples
iex> Groups.add_group("News")
{:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blog", ...}}
iex> Groups.add_group("FAQ", type: "faq", mode: "slug")
{:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}}
iex> Groups.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes")
{:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}}
"""
@spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()}
def add_group(name, opts \\ [])
def add_group(name, opts) when is_binary(name) and (is_list(opts) or is_map(opts)) do
trimmed = String.trim(name)
mode = opts |> fetch_option(:mode) |> normalize_mode_with_default()
normalized_type = opts |> fetch_option(:type) |> normalize_type()
cond do
trimmed == "" ->
{:error, :invalid_name}
is_nil(mode) ->
{:error, :invalid_mode}
is_nil(normalized_type) ->
{:error, :invalid_type}
true ->
groups = list_groups()
preferred_slug = fetch_option(opts, :slug)
with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed),
:ok <- check_slug_availability(requested_slug, groups, preferred_slug) do
slug = ensure_unique_slug(requested_slug, groups)
{default_singular, default_plural} = default_item_names(normalized_type)
item_singular =
opts
|> fetch_option(:item_singular)
|> normalize_item_name(default_singular)
item_plural =
opts
|> fetch_option(:item_plural)
|> normalize_item_name(default_plural)
db_attrs = %{
name: trimmed,
slug: slug,
mode: mode,
data: %{
"type" => normalized_type,
"item_singular" => item_singular,
"item_plural" => item_plural
}
}
case DBStorage.create_group(db_attrs) do
{:ok, db_group} ->
group = db_group_to_map(db_group)
PublishingPubSub.broadcast_group_created(group)
{:ok, group}
{:error, _changeset} ->
{:error, :already_exists}
end
end
end
end
@doc """
Removes a publishing group by slug.
"""
@spec remove_group(String.t()) :: {:ok, any()} | {:error, any()}
def remove_group(slug) when is_binary(slug) do
remove_group(slug, force: false)
end
@doc """
Removes a publishing group by slug.
By default, refuses to delete groups that contain posts.
Pass `force: true` to cascade-delete the group and all its posts.
"""
def remove_group(slug, opts) when is_binary(slug) do
force = Keyword.get(opts, :force, false)
case DBStorage.get_group_by_slug(slug) do
nil ->
{:error, :not_found}
db_group ->
post_count = DBStorage.count_posts(db_group.slug)
if post_count > 0 and not force do
{:error, {:has_posts, post_count}}
else
case DBStorage.delete_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
{:ok, slug}
error ->
error
end
end
end
end
@doc """
Updates a publishing group's display name and slug.
"""
@spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()}
def update_group(slug, params) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
{:error, :not_found}
db_group ->
with {:ok, name} <- extract_and_validate_name(db_group, params),
{:ok, sanitized_slug} <- extract_and_validate_slug(db_group, params, name) do
case DBStorage.update_group(db_group, %{name: name, slug: sanitized_slug}) do
{:ok, updated} ->
group = db_group_to_map(updated)
PublishingPubSub.broadcast_group_updated(group)
{:ok, group}
{:error, _} = error ->
error
end
end
end
end
@doc """
Moves a publishing group to trash (soft-delete).
Sets the group status to "trashed". The group and its posts remain in the
database and can be restored. Trashed groups are hidden from list_groups/0.
"""
@spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()}
def trash_group(slug) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
{:error, :not_found}
db_group ->
case DBStorage.trash_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
{:ok, slug}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Restores a trashed publishing group.
"""
@spec restore_group(String.t()) :: {:ok, String.t()} | {:error, any()}
def restore_group(slug) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
{:error, :not_found}
db_group ->
# Check if an active group already uses this slug (created while this was trashed)
active_conflict =
DBStorage.list_groups("active")
|> Enum.any?(fn g -> g.slug == slug and g.uuid != db_group.uuid end)
if active_conflict do
{:error, :slug_taken}
else
case DBStorage.restore_group(db_group) do
{:ok, _} ->
ListingCache.regenerate(slug)
PublishingPubSub.broadcast_group_created(%{
"slug" => slug,
"name" => db_group.name
})
{:ok, slug}
{:error, reason} ->
{:error, reason}
end
end
end
end
@doc """
Lists trashed publishing groups.
"""
@spec list_trashed_groups() :: [map()]
def list_trashed_groups do
DBStorage.list_groups("trashed")
|> Enum.map(&db_group_to_map/1)
end
@doc """
Looks up a publishing group name from its slug.
"""
@spec group_name(String.t()) :: String.t() | nil
def group_name(slug) do
case DBStorage.get_group_by_slug(slug) do
nil -> nil
db_group -> db_group.name
end
end
@doc """
Returns the configured post mode for a publishing group slug.
"""
@spec get_group_mode(String.t()) :: String.t()
def get_group_mode(group_slug) do
case DBStorage.get_group_by_slug(group_slug) do
nil -> @default_group_mode
db_group -> db_group.mode || @default_group_mode
end
end
@doc """
Returns the preset content types with their default item names.
"""
@spec preset_types() :: [map()]
def preset_types do
[
%{type: "blog", label: "Blog", item_singular: "post", item_plural: "posts"},
%{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"},
%{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"}
]
end
@doc """
Returns the list of valid group type values.
"""
@spec valid_types() :: [String.t()]
def valid_types, do: @valid_types
# ============================================================================
# Private Helpers
# ============================================================================
defp extract_and_validate_name(db_group, params) do
name =
params
|> fetch_option(:name)
|> case do
nil -> db_group.name
value -> String.trim(to_string(value || ""))
end
if name == "", do: {:error, :invalid_name}, else: {:ok, name}
end
defp extract_and_validate_slug(db_group, params, name) do
desired_slug =
params
|> fetch_option(:slug)
|> case do
nil -> db_group.slug
value -> String.trim(to_string(value || ""))
end
cond do
desired_slug == "" ->
auto_slug = Publishing.slugify(name)
if Publishing.valid_slug?(auto_slug),
do: {:ok, auto_slug},
else: {:error, :invalid_slug}
Publishing.valid_slug?(desired_slug) ->
{:ok, desired_slug}
true ->
{:error, :invalid_slug}
end
end
defp db_group_to_map(%{name: name, slug: slug, mode: mode, status: status, data: data}) do
%{
"name" => name,
"slug" => slug,
"mode" => mode || @default_group_mode,
"status" => status || "active",
"type" => Map.get(data, "type", @default_group_type),
"item_singular" => Map.get(data, "item_singular", @default_item_singular),
"item_plural" => Map.get(data, "item_plural", @default_item_plural)
}
end
defp derive_requested_slug(nil, fallback_name) do
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
defp derive_requested_slug(slug, fallback_name) when is_binary(slug) do
trimmed = slug |> String.trim()
cond do
trimmed == "" ->
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
Publishing.valid_slug?(trimmed) ->
{:ok, trimmed}
true ->
{:error, :invalid_slug}
end
end
defp derive_requested_slug(_other, fallback_name) do
slugified = Publishing.slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
# Check if explicit slug already exists (only when preferred_slug is provided)
defp check_slug_availability(slug, groups, preferred_slug) when not is_nil(preferred_slug) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
{:error, :already_exists}
else
:ok
end
end
defp check_slug_availability(_slug, _groups, nil), do: :ok
defp ensure_unique_slug(slug, groups), do: ensure_unique_slug(slug, groups, 2)
defp ensure_unique_slug(slug, groups, counter) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
ensure_unique_slug("#{slug}-#{counter}", groups, counter + 1)
else
slug
end
end
defp normalize_mode(mode) when is_binary(mode) do
mode
|> String.downcase()
|> case do
"slug" -> "slug"
"timestamp" -> "timestamp"
_ -> nil
end
end
defp normalize_mode(mode) when is_atom(mode), do: normalize_mode(Atom.to_string(mode))
defp normalize_mode(_), do: nil
# Normalize mode with default fallback
defp normalize_mode_with_default(nil), do: @default_group_mode
defp normalize_mode_with_default(mode), do: normalize_mode(mode) || @default_group_mode
# Normalize and validate type
# Preset types are passed through, custom types are validated and normalized
defp normalize_type(nil), do: @default_group_type
defp normalize_type(type) when is_binary(type) do
trimmed = String.trim(type)
downcased = String.downcase(trimmed)
cond do
# Preset type - pass through as-is
downcased in @preset_types ->
downcased
# Empty after trim - use default
trimmed == "" ->
@default_group_type
# Custom type - validate format
true ->
# Normalize: downcase, replace spaces/underscores with hyphens
normalized =
downcased
|> String.replace(~r/[\s_]+/, "-")
|> String.replace(~r/[^a-z0-9-]/, "")
|> String.slice(0, 32)
# Validate against type regex
if Regex.match?(@type_regex, normalized) do
normalized
else
nil
end
end
end
defp normalize_type(type) when is_atom(type), do: normalize_type(Atom.to_string(type))
defp normalize_type(_), do: nil
# Get default item names for a type
defp default_item_names(type) do
Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural})
end
# Normalize item name, using default if nil/empty
defp normalize_item_name(nil, default), do: default
defp normalize_item_name("", default), do: default
defp normalize_item_name(name, default) when is_binary(name) do
trimmed = String.trim(name)
if trimmed == "", do: default, else: trimmed
end
defp normalize_item_name(_, default), do: default
@doc false
defdelegate fetch_option(opts, key), to: Shared
end