Packages
phoenix_kit
1.7.76
1.7.208
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/stale_fixer.ex
defmodule PhoenixKit.Modules.Publishing.StaleFixer do
@moduledoc """
Fixes stale or invalid values on publishing records.
Validates and corrects fields like mode, type, status, language, and
timestamps across groups, posts, versions, and content. Also reconciles
status consistency between posts, versions, and content rows.
"""
require Logger
import Ecto.Query, only: [from: 2]
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.PublishingGroup
alias PhoenixKit.Modules.Publishing.PublishingPost
alias PhoenixKit.Modules.Publishing.Constants
# Posts younger than this are skipped by the stale fixer's empty-post deletion
@grace_period_seconds 300
@valid_types Constants.valid_types()
@valid_group_modes Constants.valid_modes()
@valid_post_statuses Constants.post_statuses()
@valid_group_statuses Constants.group_statuses()
@valid_version_statuses Constants.content_statuses()
@default_group_mode Constants.default_mode()
@default_group_type Constants.default_type()
@type_item_names %{
"blog" => {"post", "posts"},
"faq" => {"question", "questions"},
"legal" => {"document", "documents"}
}
@default_item_singular "item"
@default_item_plural "items"
@doc """
Fixes stale or invalid values on a publishing group record.
Checks and corrects:
- `mode` — must be "timestamp" or "slug" (defaults to "timestamp")
- `data.type` — must be in valid_types (defaults to "custom")
- `data.item_singular` — must be a non-empty string (defaults based on type)
- `data.item_plural` — must be a non-empty string (defaults based on type)
Can be called explicitly or runs lazily when groups are loaded in the admin.
Returns the group unchanged if no fixes are needed.
"""
@spec fix_stale_group(PublishingGroup.t()) :: PublishingGroup.t()
def fix_stale_group(%PublishingGroup{} = group) do
attrs = build_group_fixes(group)
apply_stale_fix(group, attrs, &DBStorage.update_group/2)
end
defp build_group_fixes(group) do
data = group.data || %{}
type = Map.get(data, "type", @default_group_type)
fixed_type = if type in @valid_types, do: type, else: "custom"
fixed_mode = if group.mode in @valid_group_modes, do: group.mode, else: @default_group_mode
fixed_status = if group.status in @valid_group_statuses, do: group.status, else: "active"
{default_singular, default_plural} = default_item_names(fixed_type)
item_singular = Map.get(data, "item_singular")
item_plural = Map.get(data, "item_plural")
fixed_singular = valid_string_or_default(item_singular, default_singular)
fixed_plural = valid_string_or_default(item_plural, default_plural)
data_changes =
data
|> maybe_update("type", type, fixed_type)
|> maybe_update("item_singular", item_singular, fixed_singular)
|> maybe_update("item_plural", item_plural, fixed_plural)
attrs = if data_changes != data, do: %{data: data_changes}, else: %{}
attrs = if fixed_mode != group.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs
if fixed_status != group.status, do: Map.put(attrs, :status, fixed_status), else: attrs
end
defp valid_string_or_default(val, default) do
if is_binary(val) and val != "", do: val, else: default
end
@doc """
Fixes stale or invalid values on a publishing post record.
Checks and corrects:
- `primary_language` — must be a recognized language code. Resolution order:
1. Tries to resolve a dialect (e.g., "en" → "en-US")
2. Falls back to the first available language on the post
3. Falls back to the system primary language
- `status` — must be a valid post status (defaults to "draft")
- `mode` — must be "timestamp" or "slug" (defaults to "timestamp")
- `post_date`/`post_time` — must be present for timestamp mode posts
Only fixes languages not in the master predefined list — languages that were
added, used, then removed from enabled are left untouched.
"""
@spec fix_stale_post(PublishingPost.t()) :: PublishingPost.t()
def fix_stale_post(%PublishingPost{} = post) do
# Pre-fetch all versions and contents once to avoid redundant queries
ctx = build_post_context(post)
do_fix_stale_post(post, ctx)
end
defp build_post_context(post) do
versions = DBStorage.list_versions(post.uuid)
version_uuids = Enum.map(versions, & &1.uuid)
contents_by_version = DBStorage.batch_load_contents(version_uuids)
%{versions: versions, contents_by_version: contents_by_version}
end
defp do_fix_stale_post(post, ctx) do
# Hard-delete empty posts (no content in any version) — they're abandoned
# drafts with no recoverable content, so trashing them just creates a
# restore → auto-trash loop. Skip recently created posts to avoid killing
# posts before the editor has had a chance to autosave.
if empty_post?(ctx) and past_grace_period?(post) do
Logger.info("[Publishing] Deleting empty post #{post.uuid} (no content in any version)")
DBStorage.delete_post(post)
post
else
post = apply_stale_fix(post, build_post_fixes(post, ctx), &DBStorage.update_post/2)
# Fix version/content-level issues, also fix stale versions and contents
fix_missing_primary_content(post, ctx)
fix_multiple_published_versions(post, ctx)
fix_translation_status_consistency(post, ctx)
for version <- ctx.versions do
fix_stale_version(version)
contents = Map.get(ctx.contents_by_version, version.uuid, [])
Enum.each(contents, &fix_stale_content/1)
end
DBStorage.get_post_by_uuid(post.uuid, [:group]) || post
end
end
defp empty_post?(ctx) do
if ctx.versions == [] do
true
else
Enum.all?(ctx.versions, fn version ->
contents = Map.get(ctx.contents_by_version, version.uuid, [])
contents == [] or
Enum.all?(contents, fn c ->
(c.content || "") == "" and (c.title || "") in ["", Constants.default_title()]
end)
end)
end
end
defp past_grace_period?(post) do
case post.inserted_at do
nil -> true
inserted_at -> DateTime.diff(DateTime.utc_now(), inserted_at) >= @grace_period_seconds
end
end
defp build_post_fixes(post, ctx) do
%{}
|> maybe_fix_post_language(post, ctx)
|> maybe_fix_post_status(post)
|> maybe_fix_post_mode(post)
|> maybe_fix_post_slug(post, ctx)
|> maybe_fix_post_timestamp(post)
end
defp maybe_fix_post_language(attrs, post, ctx) do
case fix_stale_language(post, ctx) do
nil -> attrs
fixed_lang -> Map.put(attrs, :primary_language, fixed_lang)
end
end
defp maybe_fix_post_status(attrs, post) do
cond do
post.status in @valid_post_statuses -> attrs
# Convert removed "scheduled" status to "draft"
post.status == "scheduled" -> Map.put(attrs, :status, "draft")
true -> Map.put(attrs, :status, "draft")
end
end
defp maybe_fix_post_mode(attrs, post) do
# First ensure the mode is a valid value
fixed_mode = if post.mode in @valid_group_modes, do: post.mode, else: @default_group_mode
# Then sync with the group's mode if they differ (e.g., group switched from
# timestamp to slug but existing posts were never updated)
group = if post.group, do: post.group, else: DBStorage.get_group(post.group_uuid)
fixed_mode =
if group && group.mode in @valid_group_modes do
group.mode
else
fixed_mode
end
if fixed_mode != post.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs
end
defp maybe_fix_post_slug(attrs, post, ctx) do
effective_mode = attrs[:mode] || post.mode
if effective_mode == "slug" and (is_nil(post.slug) or post.slug == "") do
# Post switched to slug mode but has no slug — generate from title or date
base_slug = generate_slug_for_post(post, ctx)
if base_slug != "" do
# Ensure uniqueness by checking if slug exists in the group
slug = ensure_unique_slug(post.group_uuid, base_slug, post.uuid)
Logger.info(
"[Publishing] Generating slug for post #{post.uuid}: #{inspect(slug)} (mode changed to slug)"
)
Map.put(attrs, :slug, slug)
else
Logger.warning(
"[Publishing] Failed to generate slug for post #{post.uuid} — post will be unreachable in slug mode"
)
attrs
end
else
attrs
end
end
# Ensures a slug is unique within a group by appending a UUID suffix if needed
defp ensure_unique_slug(group_uuid, slug, post_uuid) do
conflict =
from(p in PublishingPost,
where: p.group_uuid == ^group_uuid and p.slug == ^slug and p.uuid != ^post_uuid,
select: p.uuid,
limit: 1
)
|> PhoenixKit.RepoHelper.repo().one()
if conflict do
suffix = String.slice(post_uuid || "", 0, 8)
"#{slug}-#{suffix}"
else
slug
end
end
defp generate_slug_for_post(post, ctx) do
# Try to get title from the latest version's primary content
title =
case ctx.versions do
[] ->
nil
versions ->
latest = List.last(versions)
contents = Map.get(ctx.contents_by_version, latest.uuid, [])
case Enum.find(contents, &(&1.language == post.primary_language)) do
nil -> nil
content -> content.title
end
end
base =
cond do
is_binary(title) and title not in ["", Constants.default_title()] ->
title
post.post_date ->
Date.to_iso8601(post.post_date)
true ->
"post-#{String.slice(post.uuid || "", 0, 8)}"
end
base
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
defp maybe_fix_post_timestamp(attrs, post) do
if (attrs[:mode] || post.mode) == "timestamp" do
now = DateTime.utc_now()
attrs
|> then(fn a ->
if is_nil(post.post_date), do: Map.put(a, :post_date, DateTime.to_date(now)), else: a
end)
|> then(fn a ->
if is_nil(post.post_time),
do: Map.put(a, :post_time, Time.new!(now.hour, now.minute, 0)),
else: a
end)
else
attrs
end
end
# Returns the fixed language or nil if no fix needed.
defp fix_stale_language(post, ctx) do
lang = post.primary_language
if lang && Languages.get_predefined_language(lang) do
nil
else
fixed = resolve_stale_language(lang, ctx)
if fixed != lang, do: fixed, else: nil
end
end
defp resolve_stale_language(lang, ctx) do
dialect = if lang, do: Languages.DialectMapper.base_to_dialect(lang), else: nil
if dialect && Languages.get_predefined_language(dialect) do
dialect
else
available = post_available_languages(ctx)
if available != [] do
Enum.find(available, hd(available), fn code ->
Languages.get_predefined_language(code) != nil
end)
else
LanguageHelpers.get_primary_language()
end
end
end
defp post_available_languages(ctx) do
case ctx.versions do
[] ->
[]
[first | _] ->
contents = Map.get(ctx.contents_by_version, first.uuid, [])
Enum.map(contents, & &1.language)
end
end
defp apply_stale_fix(record, attrs, _update_fn) when attrs == %{}, do: record
defp apply_stale_fix(record, attrs, update_fn) do
identifier = Map.get(record, :uuid) || Map.get(record, :slug) || "unknown"
Logger.info(
"[Publishing] Fixing stale values for #{record.__struct__} #{identifier}: #{inspect(attrs)}"
)
case update_fn.(record, attrs) do
{:ok, updated} ->
updated
{:error, reason} ->
Logger.warning(
"[Publishing] Failed to fix stale values for #{identifier}: #{inspect(reason)}"
)
record
end
end
defp maybe_update(data, key, old_val, new_val) do
if old_val != new_val, do: Map.put(data, key, new_val), else: data
end
@doc """
Fixes stale values across all groups, posts, versions, and content.
Also reconciles status consistency between posts, versions, and content,
fixes missing primary language content, and ensures single published version.
Callable via internal API or IEx.
"""
@spec fix_all_stale_values() :: :ok
def fix_all_stale_values do
# Scan ALL groups (including trashed) — pass nil to skip status filter
groups = DBStorage.list_groups(nil)
Enum.each(groups, &fix_stale_group/1)
for group <- groups do
posts = DBStorage.list_posts(group.slug)
Enum.each(posts, &fix_stale_post/1)
end
:ok
end
def fix_stale_version(version) do
if version.status not in @valid_version_statuses do
Logger.info(
"[Publishing] Fixing stale version #{version.uuid}: status #{inspect(version.status)} → \"draft\""
)
DBStorage.update_version(version, %{status: "draft"})
end
end
def fix_stale_content(content) do
attrs =
%{}
|> maybe_fix_content_status(content)
|> maybe_fix_content_language(content)
if attrs != %{} do
Logger.info(
"[Publishing] Fixing stale content #{content.uuid} (#{content.language}): #{inspect(attrs)}"
)
DBStorage.update_content(content, attrs)
end
end
defp maybe_fix_content_status(attrs, content) do
if content.status in @valid_version_statuses,
do: attrs,
else: Map.put(attrs, :status, "draft")
end
defp maybe_fix_content_language(attrs, content) do
if is_binary(content.language) and content.language != "" do
attrs
else
Map.put(attrs, :language, LanguageHelpers.get_primary_language())
end
end
@doc """
Fixes missing primary language content.
If the primary language has no content (or empty content) but a translation
exists with content, copies the best translation to the primary language
and inherits its status.
"""
def fix_missing_primary_content(%PublishingPost{} = post) do
ctx = build_post_context(post)
fix_missing_primary_content(post, ctx)
end
defp fix_missing_primary_content(post, ctx) do
primary_lang = post.primary_language
for version <- ctx.versions do
contents = Map.get(ctx.contents_by_version, version.uuid, [])
primary_content = Enum.find(contents, &(&1.language == primary_lang))
if primary_content_missing?(primary_content) and contents != [] do
fix_version_primary_content(post, version, primary_lang, primary_content, contents)
end
end
end
defp primary_content_missing?(nil), do: true
defp primary_content_missing?(content) do
content.content in [nil, ""] and content.title in [nil, "", Constants.default_title()]
end
defp fix_version_primary_content(post, version, primary_lang, primary_content, contents) do
source = find_best_translation(contents, primary_lang)
if source do
Logger.info(
"[Publishing] Fixing missing primary content for post #{post.uuid}/v#{version.version_number}: " <>
"copying from #{source.language} to #{primary_lang}"
)
copy_translation_to_primary(version, primary_lang, primary_content, source)
maybe_promote_post_to_published(post, source)
end
end
defp find_best_translation(contents, primary_lang) do
Enum.find(contents, fn c ->
c.language != primary_lang and c.status == "published" and c.content not in [nil, ""]
end) ||
Enum.find(contents, fn c ->
c.language != primary_lang and c.content not in [nil, ""]
end)
end
defp copy_translation_to_primary(version, primary_lang, nil, source) do
DBStorage.create_content(%{
version_uuid: version.uuid,
language: primary_lang,
title: source.title,
content: source.content,
status: source.status,
url_slug: source.url_slug
})
end
defp copy_translation_to_primary(_version, _primary_lang, primary_content, source) do
DBStorage.update_content(primary_content, %{
title: source.title,
content: source.content,
status: source.status,
url_slug: primary_content.url_slug || source.url_slug
})
end
defp maybe_promote_post_to_published(post, source) do
if source.status == "published" and post.status != "published" do
Logger.info(
"[Publishing] Promoting post #{post.uuid} to published (primary content now has published content)"
)
DBStorage.update_post(post, %{
status: "published",
published_at: post.published_at || DateTime.utc_now()
})
end
end
@doc """
Ensures only one version is published per post.
If multiple versions have status "published", keeps the highest version
number as published and archives the rest.
"""
def fix_multiple_published_versions(%PublishingPost{} = post) do
ctx = build_post_context(post)
fix_multiple_published_versions(post, ctx)
end
defp fix_multiple_published_versions(post, ctx) do
published = Enum.filter(ctx.versions, &(&1.status == "published"))
if length(published) > 1 do
# Keep the highest version number, archive the rest
sorted = Enum.sort_by(published, & &1.version_number, :desc)
[keep | demote] = sorted
Logger.info(
"[Publishing] Post #{post.uuid} has #{length(published)} published versions, " <>
"keeping v#{keep.version_number}, archiving #{length(demote)} others"
)
for v <- demote do
DBStorage.update_version(v, %{status: "archived"})
DBStorage.update_content_status(v.uuid, "archived")
end
end
end
@doc """
Ensures translation statuses follow the primary language's status.
If the primary language content is not published (draft/archived/trashed)
but a translation is published, demotes the translation to match the
primary's status. Translations should never be published when the primary isn't.
"""
def fix_translation_status_consistency(%PublishingPost{} = post) do
ctx = build_post_context(post)
fix_translation_status_consistency(post, ctx)
end
defp fix_translation_status_consistency(post, ctx) do
primary_lang = post.primary_language
for version <- ctx.versions do
contents = Map.get(ctx.contents_by_version, version.uuid, [])
primary_content = Enum.find(contents, &(&1.language == primary_lang))
primary_status = if primary_content, do: primary_content.status, else: post.status
if primary_status != "published" do
# Demote any translations that are published when primary isn't
for content <- contents,
content.language != primary_lang,
content.status == "published" do
Logger.info(
"[Publishing] Demoting translation #{content.language} from published to #{primary_status} " <>
"for post #{post.uuid}/v#{version.version_number} (primary is #{primary_status})"
)
DBStorage.update_content(content, %{status: primary_status})
end
end
end
end
# Reconciles status consistency between a post, its versions, and content.
#
# Rules enforced:
# 1. Post "published" requires at least one "published" version → else demote to "draft"
# 2. Version "published" requires its post to be "published" → else archive the version
# 3. Content "published" requires its version to be "published" → else demote to "draft"
# 4. Non-published versions cannot have "published" content → demote content to "draft"
#
# Note: individual translations CAN be "draft" while the version is "published" —
# this is the normal state for untranslated languages. We only fix content that
# claims to be "published" when it shouldn't be.
def reconcile_post_status(%PublishingPost{} = post) do
# Re-read to get current state after individual fixes
post = DBStorage.get_post_by_uuid(post.uuid) || post
versions = DBStorage.list_versions(post.uuid)
published_versions = Enum.filter(versions, &(&1.status == "published"))
cond do
# Post says published but no version backs it up
post.status == "published" and published_versions == [] ->
Logger.info(
"[Publishing] Reconcile: post #{post.uuid} is published but has no published versions, demoting to draft"
)
DBStorage.update_post(post, %{status: "draft"})
# Post is not published but a version claims to be — archive the version
post.status in ["draft", "archived", "trashed"] and published_versions != [] ->
Logger.info(
"[Publishing] Reconcile: post #{post.uuid} is #{inspect(post.status)} but has #{length(published_versions)} published versions, archiving"
)
for v <- published_versions do
DBStorage.update_version(v, %{status: "archived"})
demote_published_content(v.uuid)
end
true ->
:ok
end
# For ALL non-published versions, no content should be "published"
non_published_versions = Enum.reject(versions, &(&1.status == "published"))
for v <- non_published_versions do
demote_published_content(v.uuid)
end
end
# Demotes any "published" content rows to "draft" within a version.
# Leaves "draft" and "archived" content untouched.
defp demote_published_content(version_uuid) do
contents = DBStorage.list_contents(version_uuid)
published = Enum.filter(contents, &(&1.status == "published"))
if published != [] do
Logger.info(
"[Publishing] Demoting #{length(published)} published content row(s) to \"draft\" in version #{version_uuid}"
)
for content <- published do
DBStorage.update_content(content, %{status: "draft"})
end
end
end
# Returns the default item names for a given type.
defp default_item_names(type) do
Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural})
end
end