Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing publishing.ex
Raw

lib/phoenix_kit_publishing/publishing.ex

defmodule PhoenixKit.Modules.Publishing do
@moduledoc """
Publishing module for managing content groups and their posts.
Database-backed CMS for creating timestamped or slug-based posts
with multi-language support and versioning.
This module acts as a facade, delegating to focused submodules:
- `Publishing.Groups` — Group CRUD
- `Publishing.Posts` — Post CRUD, reading, and listing
- `Publishing.Versions` — Version create, publish, delete
- `Publishing.TranslationManager` — Language/translation management
- `Publishing.StaleFixer` — Stale value detection and repair
"""
use PhoenixKit.Module
require Logger
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Publishing.ActivityLog
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.SlugHelpers
# ============================================================================
# Language Utility Delegates
# ============================================================================
defdelegate get_language_info(language_code), to: LanguageHelpers
defdelegate enabled_language_codes(), to: LanguageHelpers
defdelegate get_primary_language(), to: LanguageHelpers
defdelegate get_primary_language_base(), to: LanguageHelpers
defdelegate default_language_no_prefix?(), to: LanguageHelpers
defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers
defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers
defdelegate use_language_prefix?(language_code), to: LanguageHelpers
defdelegate url_language_code(language_code), to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages),
to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages, primary),
to: LanguageHelpers
# ============================================================================
# Slug Utility Delegates
# ============================================================================
defdelegate validate_slug(slug), to: SlugHelpers
defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers
defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers
defdelegate clear_url_slug_from_post(group_slug, post_slug, url_slug), to: DBStorage
# ============================================================================
# Cache Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.ListingCache
defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate
defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate
defdelegate cache_exists?(group_slug), to: ListingCache, as: :exists?
defdelegate find_cached_post(group_slug, post_slug), to: ListingCache, as: :find_post
defdelegate find_cached_post_by_path(group_slug, date, time),
to: ListingCache,
as: :find_post_by_path
# ============================================================================
# Group Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Groups
defdelegate list_groups(), to: Groups
defdelegate list_groups(status), to: Groups
defdelegate get_group(slug), to: Groups
defdelegate add_group(name, opts \\ []), to: Groups
defdelegate remove_group(slug), to: Groups
defdelegate remove_group(slug, opts), to: Groups
defdelegate update_group(slug, params), to: Groups
defdelegate update_group(slug, params, opts), to: Groups
defdelegate trash_group(slug), to: Groups
defdelegate trash_group(slug, opts), to: Groups
defdelegate group_name(slug), to: Groups
defdelegate get_group_mode(group_slug), to: Groups
defdelegate preset_types(), to: Groups
defdelegate valid_types(), to: Groups
defdelegate restore_group(slug), to: Groups
defdelegate restore_group(slug, opts), to: Groups
defdelegate list_trashed_groups(), to: Groups
# ============================================================================
# Post Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Posts
defdelegate list_posts(group_slug, preferred_language \\ nil), to: Posts
defdelegate list_posts_by_status(group_slug, status), to: Posts
defdelegate list_raw_posts(group_slug, status \\ nil), to: Posts
defdelegate create_post(group_slug, opts \\ %{}), to: Posts
defdelegate read_post(group_slug, identifier, language \\ nil, version \\ nil), to: Posts
defdelegate read_post_by_uuid(post_uuid, language \\ nil, version \\ nil), to: Posts
defdelegate update_post(group_slug, post, params, opts \\ %{}), to: Posts
defdelegate trash_post(group_slug, post_uuid), to: Posts
defdelegate trash_post(group_slug, post_uuid, opts), to: Posts
defdelegate restore_post(group_slug, post_uuid), to: Posts
defdelegate restore_post(group_slug, post_uuid, opts), to: Posts
defdelegate count_posts_on_date(group_slug, date), to: Posts
defdelegate list_times_on_date(group_slug, date), to: Posts
defdelegate read_post_by_datetime(group_slug, date, time), to: DBStorage
defdelegate find_by_url_slug(group_slug, language, url_slug), to: Posts
defdelegate find_by_previous_url_slug(group_slug, language, url_slug), to: Posts
defdelegate extract_slug_version_and_language(group_slug, identifier), to: Posts
@doc """
Stub: always returns `false`. Versioning is opt-in via the editor's
explicit "create new version" action, not implicit on every save —
so this predicate is intentionally a no-op kept for API stability
with older callers that branch on it.
"""
@spec should_create_new_version?(map(), map(), String.t()) :: false
def should_create_new_version?(_post, _params, _editing_language), do: false
@doc "Returns true when the given post is a DB-backed post (has a UUID)."
@spec db_post?(map()) :: boolean()
defdelegate db_post?(post), to: Posts
# ============================================================================
# Version Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.Versions
defdelegate list_versions(group_slug, post_slug), to: Versions
defdelegate get_published_version(group_slug, post_slug), to: Versions
defdelegate get_version_status(group_slug, post_slug, version_number, language), to: Versions
defdelegate get_version_metadata(group_slug, post_slug, version_number, language), to: Versions
defdelegate create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}),
to: Versions
defdelegate publish_version(group_slug, post_uuid, version, opts \\ []), to: Versions
defdelegate create_version_from(
group_slug,
post_uuid,
source_version,
params \\ %{},
opts \\ %{}
),
to: Versions
defdelegate unpublish_post(group_slug, post_uuid, opts \\ []), to: Versions
defdelegate delete_version(group_slug, post_uuid, version), to: Versions
defdelegate delete_version(group_slug, post_uuid, version, opts), to: Versions
@doc false
defdelegate broadcast_version_created(group_slug, broadcast_id, new_version), to: Versions
# ============================================================================
# Translation Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.TranslationManager
defdelegate add_language_to_post(group_slug, post_uuid, language_code, version, opts),
to: TranslationManager
defdelegate add_language_to_post(group_slug, post_uuid, language_code, version \\ nil),
to: TranslationManager
@doc false
defdelegate add_language_to_db(group_slug, post_uuid, language_code, version_number),
to: TranslationManager
defdelegate delete_language(group_slug, post_uuid, language_code, version, opts),
to: TranslationManager
defdelegate delete_language(group_slug, post_uuid, language_code, version \\ nil),
to: TranslationManager
defdelegate clear_translation(group_slug, post_uuid, language_code, opts \\ []),
to: TranslationManager
defdelegate set_translation_status(group_slug, post_identifier, version, language, status),
to: TranslationManager
defdelegate translate_post_to_all_languages(group_slug, post_uuid, opts \\ []),
to: TranslationManager
# ============================================================================
# Stale Value Correction Delegates
# ============================================================================
alias PhoenixKit.Modules.Publishing.StaleFixer
defdelegate fix_stale_group(group), to: StaleFixer
defdelegate fix_stale_post(post), to: StaleFixer
defdelegate fix_stale_version(version), to: StaleFixer
defdelegate fix_stale_content(content), to: StaleFixer
defdelegate fix_all_stale_values(), to: StaleFixer
defdelegate reconcile_post_status(post), to: StaleFixer
# ============================================================================
# Module Behaviour Callbacks
# ============================================================================
@publishing_enabled_key "publishing_enabled"
@impl PhoenixKit.Module
@spec enabled?() :: boolean()
def enabled? do
settings_call(:get_boolean_setting, [@publishing_enabled_key, false])
end
@impl PhoenixKit.Module
@spec ai_translatables() :: [{String.t(), module()}]
def ai_translatables do
[{PhoenixKitPublishing.AITranslatable.resource_type(), PhoenixKitPublishing.AITranslatable}]
end
@impl PhoenixKit.Module
@spec enable_system() :: {:ok, any()} | {:error, any()}
def enable_system do
result = settings_call(:update_boolean_setting, [@publishing_enabled_key, true])
with {:ok, _} <- result do
ActivityLog.log_manual(
"publishing.module.enabled",
nil,
"publishing_module",
nil,
%{}
)
end
result
end
@impl PhoenixKit.Module
@spec disable_system() :: {:ok, any()} | {:error, any()}
def disable_system do
result = settings_call(:update_boolean_setting, [@publishing_enabled_key, false])
with {:ok, _} <- result do
ActivityLog.log_manual(
"publishing.module.disabled",
nil,
"publishing_module",
nil,
%{}
)
end
result
end
@impl PhoenixKit.Module
def module_key, do: "publishing"
@impl PhoenixKit.Module
def module_name, do: "Publishing"
@impl PhoenixKit.Module
def version, do: "0.1.1"
@impl PhoenixKit.Module
def get_config do
%{
enabled: enabled?(),
groups_count: length(list_groups())
}
end
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "publishing",
label: "Publishing",
icon: "hero-document-duplicate",
description: "Database-backed CMS pages and multi-language content"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 600,
level: :admin,
permission: "publishing",
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
dynamic_children: &__MODULE__.publishing_children/1
)
]
end
@doc "Dynamic children function for Publishing sidebar tabs."
def publishing_children(_scope) do
groups = load_publishing_groups_for_tabs()
groups
|> Enum.with_index()
|> Enum.map(fn {group, idx} ->
slug = group["slug"] || ""
name = group["name"] || slug
hash = :erlang.phash2(slug) |> Integer.to_string(16) |> String.downcase()
sanitized = slug |> String.replace(~r/[^a-zA-Z0-9_]/, "_") |> String.slice(0, 50)
%Tab{
id: :"admin_publishing_#{sanitized}_#{hash}",
label: name,
icon: "hero-document-text",
path: "publishing/#{slug}",
priority: 601 + idx,
level: :admin,
permission: "publishing",
match: :prefix,
parent: :admin_publishing
}
end)
rescue
# Narrow: dashboard tabs are rendered at every admin LV mount, so a
# transient DB blip shouldn't crash the whole admin shell. Genuine
# programmer errors (UndefinedFunctionError, MatchError, …) should
# still bubble up so the bug isn't masked.
e in [Ecto.QueryError, DBConnection.ConnectionError, Postgrex.Error] ->
Logger.warning("[Publishing] dashboard_tabs DB failure: #{inspect(e)}")
[]
end
defp load_publishing_groups_for_tabs do
alias PhoenixKit.Settings
publishing_enabled = Settings.get_boolean_setting("publishing_enabled", false)
if publishing_enabled do
alias PhoenixKit.Modules.Publishing.DBStorage
DBStorage.list_groups()
|> Enum.map(fn g -> %{"name" => g.name, "slug" => g.slug} end)
else
[]
end
rescue
e in [Ecto.QueryError, DBConnection.ConnectionError, Postgrex.Error] ->
Logger.warning("[Publishing] load_publishing_groups_for_tabs DB failure: #{inspect(e)}")
[]
end
@impl PhoenixKit.Module
def settings_tabs do
[
Tab.new!(
id: :admin_settings_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 921,
level: :admin,
parent: :admin_settings,
permission: "publishing"
)
]
end
@impl PhoenixKit.Module
def children, do: [PhoenixKit.Modules.Publishing.Presence]
@impl PhoenixKit.Module
def route_module, do: PhoenixKitPublishing.Routes
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_publishing]
# ============================================================================
# Shared Helpers (used across submodules)
# ============================================================================
alias PhoenixKit.Modules.Publishing.Shared
@doc """
Lowercases `name`, replaces non-alphanumeric runs with `-`, and trims
leading/trailing hyphens. Pairs with `valid_slug?/1` — callers should
re-validate the result because slug-shape is enforced separately from
generation (reserved language codes, empty results).
## Examples
iex> PhoenixKit.Modules.Publishing.slugify("My First Post!")
"my-first-post"
iex> PhoenixKit.Modules.Publishing.slugify(" spaces & ampersands ")
"spaces-ampersands"
"""
@spec slugify(String.t()) :: String.t()
def slugify(name) when is_binary(name) do
SlugHelpers.slugify(name)
end
@doc """
Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern,
and is not a reserved language code.
Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity.
"""
@spec valid_slug?(any()) :: boolean()
def valid_slug?(slug) when is_binary(slug) do
slug != "" and SlugHelpers.matches_shape?(slug) and not reserved_language_code?(slug)
end
def valid_slug?(_), do: false
defp reserved_language_code?(slug) do
language_codes =
try do
Languages.get_language_codes()
rescue
e ->
Logger.debug(
"[Publishing] reserved_language_code? check failed, assuming no reserved codes: #{inspect(e)}"
)
[]
end
slug in language_codes
end
@doc false
defdelegate fetch_option(opts, key), to: Shared
@doc false
defdelegate audit_metadata(scope, action), to: Shared
# ============================================================================
# Settings Helpers (private)
# ============================================================================
defp settings_module do
case PhoenixKit.Config.get(:publishing_settings_module) do
:not_found -> PhoenixKit.Settings
{:ok, module} -> module
end
end
defp settings_call(fun, args) do
module = settings_module()
apply(module, fun, args)
end
end