Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing groups.ex
Raw

lib/phoenix_kit_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.ActivityLog
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 == "" ->
log_failed_group_create(opts, "", "invalid_name")
{:error, :invalid_name}
is_nil(mode) ->
log_failed_group_create(opts, trimmed, "invalid_mode")
{:error, :invalid_mode}
is_nil(normalized_type) ->
log_failed_group_create(opts, trimmed, "invalid_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
}
}
create_and_broadcast_group(db_attrs, opts)
else
{:error, reason} = err ->
log_failed_group_create(opts, trimmed, to_string(reason))
err
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.
"""
@spec remove_group(String.t(), keyword()) :: {:ok, any()} | {:error, any()}
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 ->
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
post_count = DBStorage.count_posts(db_group.slug)
if post_count > 0 and not force do
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => "has_posts", "post_count" => post_count}
)
{:error, {:has_posts, post_count}}
else
delete_and_broadcast_group(db_group, slug, opts)
end
end
end
@doc """
Updates a publishing group's display name and slug.
"""
@spec update_group(String.t(), map() | keyword(), keyword() | map()) ::
{:ok, group()} | {:error, atom()}
def update_group(slug, params, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{: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),
{:ok, updated} <-
DBStorage.update_group(db_group, %{name: name, slug: sanitized_slug}) do
# A slug rename orphans the old slug's listing cache entry — it leaks in
# :persistent_term and keeps serving stale data under the old key (L6).
if db_group.slug != updated.slug, do: ListingCache.invalidate(db_group.slug)
group = db_group_to_map(updated)
PublishingPubSub.broadcast_group_updated(group)
ActivityLog.log_manual(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
updated.uuid,
%{"slug" => updated.slug, "previous_slug" => db_group.slug}
)
{:ok, group}
else
{:error, reason} = err ->
ActivityLog.log_failed_mutation(
"publishing.group.updated",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => to_string_reason(reason)}
)
err
end
end
end
# Converts an error reason from `update_group`'s with-chain into a
# short grep-able tag for activity-log metadata. Only atoms (from the
# validation helpers) and changesets (from `DBStorage.update_group`)
# reach this — keep the matching tight so a future error shape gets
# noticed via FunctionClauseError instead of a silent "error" tag.
defp to_string_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
defp to_string_reason(%Ecto.Changeset{}), do: "changeset_error"
@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(), keyword() | map()) :: {:ok, String.t()} | {:error, any()}
def trash_group(slug, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{:error, :not_found}
db_group ->
case DBStorage.trash_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
ActivityLog.log_manual(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
{:error, reason} ->
ActivityLog.log_failed_mutation(
"publishing.group.trashed",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:error, reason}
end
end
end
@doc """
Restores a trashed publishing group.
"""
@spec restore_group(String.t(), keyword() | map()) :: {:ok, String.t()} | {:error, any()}
def restore_group(slug, opts \\ []) when is_binary(slug) do
case DBStorage.get_group_by_slug(slug) do
nil ->
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"slug" => slug, "reason" => "not_found"}
)
{: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
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug, "reason" => "slug_taken"}
)
{:error, :slug_taken}
else
restore_and_broadcast_group(db_group, slug, opts)
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
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 create_and_broadcast_group(db_attrs, opts) do
case DBStorage.create_group(db_attrs) do
{:ok, db_group} ->
group = db_group_to_map(db_group)
PublishingPubSub.broadcast_group_created(group)
ActivityLog.log_manual(
"publishing.group.created",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => db_group.slug, "name" => db_group.name, "mode" => db_group.mode}
)
{:ok, group}
{:error, _changeset} ->
log_failed_group_create(opts, db_attrs[:name] || "", "already_exists")
{:error, :already_exists}
end
end
defp delete_and_broadcast_group(db_group, slug, opts) do
case DBStorage.delete_group(db_group) do
{:ok, _} ->
ListingCache.invalidate(slug)
PublishingPubSub.broadcast_group_deleted(slug)
ActivityLog.log_manual(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
error ->
ActivityLog.log_failed_mutation(
"publishing.group.deleted",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
error
end
end
defp restore_and_broadcast_group(db_group, slug, opts) do
case DBStorage.restore_group(db_group) do
{:ok, _} ->
ListingCache.regenerate(slug)
PublishingPubSub.broadcast_group_created(%{"slug" => slug, "name" => db_group.name})
ActivityLog.log_manual(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:ok, slug}
{:error, reason} ->
ActivityLog.log_failed_mutation(
"publishing.group.restored",
ActivityLog.actor_uuid(opts),
"publishing_group",
db_group.uuid,
%{"slug" => slug}
)
{:error, reason}
end
end
# `add_group` validates inputs synchronously before reaching the DB —
# all four early-return branches log a `db_pending: true` audit row so
# the audit trail captures the user-initiated action even when the
# input never made it to a row.
defp log_failed_group_create(opts, name_attempt, reason) do
ActivityLog.log_failed_mutation(
"publishing.group.created",
ActivityLog.actor_uuid(opts),
"publishing_group",
nil,
%{"name_attempted" => name_attempt, "reason" => reason}
)
end
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