Packages
phoenix_kit
1.7.78
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/pubsub.ex
defmodule PhoenixKit.Modules.Publishing.PubSub do
@moduledoc """
PubSub integration for real-time publishing updates.
Provides broadcasting and subscription for post changes,
enabling live updates across all connected admin clients.
## Features
- Post lifecycle events (create, update, delete, status change)
- Collaborative editing with real-time form state sync
- Owner/spectator model for concurrent editing
"""
alias PhoenixKit.PubSub.Manager
@topic_prefix "publishing"
@topic_editor_forms "publishing:editor_forms"
@topic_groups "publishing:groups"
# ============================================================================
# Post Identifier Resolution
# ============================================================================
@doc """
Returns the broadcast identifier for a post.
Uses slug when available, falls back to uuid. This identifier is used
for PubSub topic construction and must be consistent between broadcasters
(e.g. translation worker) and subscribers (e.g. editor).
"""
def broadcast_id(post) do
post[:slug] || post[:uuid]
end
# ============================================================================
# Group-Level Updates (group creation/deletion)
# ============================================================================
@doc """
Returns the topic for global group updates (create, delete).
"""
def groups_topic, do: @topic_groups
@doc """
Subscribes the current process to group updates (creation/deletion).
"""
def subscribe_to_groups do
Manager.subscribe(groups_topic())
end
@doc """
Unsubscribes the current process from group updates.
"""
def unsubscribe_from_groups do
Manager.unsubscribe(groups_topic())
end
@doc """
Broadcasts a group created event.
"""
def broadcast_group_created(group) do
Manager.broadcast(groups_topic(), {:group_created, group})
end
@doc """
Broadcasts a group deleted event.
"""
def broadcast_group_deleted(group_slug) do
Manager.broadcast(groups_topic(), {:group_deleted, group_slug})
end
@doc """
Broadcasts a group updated event.
"""
def broadcast_group_updated(group) do
Manager.broadcast(groups_topic(), {:group_updated, group})
end
# ============================================================================
# Post List Updates (simple refresh)
# ============================================================================
@doc """
Returns the topic for a specific group's posts.
"""
def posts_topic(group_slug) do
"#{@topic_prefix}:#{group_slug}:posts"
end
@doc """
Subscribes the current process to post updates for a group.
"""
def subscribe_to_posts(group_slug) do
Manager.subscribe(posts_topic(group_slug))
end
@doc """
Unsubscribes the current process from post updates for a group.
"""
def unsubscribe_from_posts(group_slug) do
Manager.unsubscribe(posts_topic(group_slug))
end
@doc """
Broadcasts a post created event.
"""
def broadcast_post_created(group_slug, post) do
Manager.broadcast(posts_topic(group_slug), {:post_created, post})
end
@doc """
Broadcasts a post updated event.
"""
def broadcast_post_updated(group_slug, post) do
Manager.broadcast(posts_topic(group_slug), {:post_updated, post})
end
@doc """
Broadcasts a post deleted event.
"""
def broadcast_post_deleted(group_slug, post_identifier) do
Manager.broadcast(posts_topic(group_slug), {:post_deleted, post_identifier})
end
@doc """
Broadcasts a post status changed event.
"""
def broadcast_post_status_changed(group_slug, post) do
Manager.broadcast(posts_topic(group_slug), {:post_status_changed, post})
end
@doc """
Broadcasts that a new version was created for a post.
"""
def broadcast_version_created(group_slug, post) do
Manager.broadcast(posts_topic(group_slug), {:version_created, post})
end
@doc """
Broadcasts that the live version changed for a post.
"""
def broadcast_version_live_changed(group_slug, post_identifier, version) do
Manager.broadcast(posts_topic(group_slug), {:version_live_changed, post_identifier, version})
end
@doc """
Broadcasts that a version was deleted from a post.
"""
def broadcast_version_deleted(group_slug, post_identifier, version) do
Manager.broadcast(posts_topic(group_slug), {:version_deleted, post_identifier, version})
end
# ============================================================================
# Post-Level Updates (version and translation changes)
# ============================================================================
@doc """
Returns the topic for a specific post's version updates.
This allows editors to receive notifications when versions are created/deleted.
"""
def post_versions_topic(group_slug, post_slug) do
"#{@topic_prefix}:#{group_slug}:post:#{post_slug}:versions"
end
@doc """
Subscribes to version updates for a specific post.
"""
def subscribe_to_post_versions(group_slug, post_slug) do
Manager.subscribe(post_versions_topic(group_slug, post_slug))
end
@doc """
Unsubscribes from version updates for a specific post.
"""
def unsubscribe_from_post_versions(group_slug, post_slug) do
Manager.unsubscribe(post_versions_topic(group_slug, post_slug))
end
@doc """
Broadcasts that a new version was created for a post (to post-level topic).
"""
def broadcast_post_version_created(group_slug, post_slug, version_info) do
Manager.broadcast(
post_versions_topic(group_slug, post_slug),
{:post_version_created, group_slug, post_slug, version_info}
)
end
@doc """
Broadcasts that a version was deleted from a post (to post-level topic).
"""
def broadcast_post_version_deleted(group_slug, post_slug, version) do
Manager.broadcast(
post_versions_topic(group_slug, post_slug),
{:post_version_deleted, group_slug, post_slug, version}
)
end
@doc """
Broadcasts that the live/published version changed (to post-level topic).
Includes source_id so receivers can ignore their own broadcasts.
"""
def broadcast_post_version_published(group_slug, post_slug, version, source_id \\ nil) do
Manager.broadcast(
post_versions_topic(group_slug, post_slug),
{:post_version_published, group_slug, post_slug, version, source_id}
)
end
@doc """
Returns the topic for a specific post's translation updates.
This allows all editors of different language versions to receive updates
when new translations are added.
"""
def post_translations_topic(group_slug, post_slug) do
"#{@topic_prefix}:#{group_slug}:post:#{post_slug}:translations"
end
@doc """
Subscribes to translation updates for a specific post.
"""
def subscribe_to_post_translations(group_slug, post_slug) do
Manager.subscribe(post_translations_topic(group_slug, post_slug))
end
@doc """
Unsubscribes from translation updates for a specific post.
"""
def unsubscribe_from_post_translations(group_slug, post_slug) do
Manager.unsubscribe(post_translations_topic(group_slug, post_slug))
end
@doc """
Broadcasts that a new translation was created for a post.
"""
def broadcast_translation_created(group_slug, post_slug, language) do
Manager.broadcast(
post_translations_topic(group_slug, post_slug),
{:translation_created, group_slug, post_slug, language}
)
end
@doc """
Broadcasts that a translation was deleted from a post.
"""
def broadcast_translation_deleted(group_slug, post_slug, language) do
Manager.broadcast(
post_translations_topic(group_slug, post_slug),
{:translation_deleted, group_slug, post_slug, language}
)
end
# ============================================================================
# Editor Save Sync (last-save-wins model)
# ============================================================================
@doc """
Broadcasts that a post was saved, so other editors can reload.
The `source` is the socket.id of the saver, so they don't reload their own save.
"""
def broadcast_editor_saved(form_key, source) do
Manager.broadcast(
editor_form_topic(form_key),
{:editor_saved, form_key, source}
)
end
# ============================================================================
# Collaborative Editor (real-time form sync)
# ============================================================================
@doc """
Returns the topic for a specific editor form.
The form_key uniquely identifies a post being edited:
- For existing posts: "group_slug:post_path" or "group_slug:slug"
- For new posts: "group_slug:new:language"
"""
def editor_form_topic(form_key) do
"#{@topic_editor_forms}:#{form_key}"
end
@doc """
Returns the presence topic for tracking editors of a post.
"""
def editor_presence_topic(form_key) do
"publishing:presence:editor:#{form_key}"
end
@doc """
Subscribes to collaborative events for a specific editor form.
"""
def subscribe_to_editor_form(form_key) do
Manager.subscribe(editor_form_topic(form_key))
end
@doc """
Unsubscribes from collaborative events for a specific editor form.
"""
def unsubscribe_from_editor_form(form_key) do
Manager.unsubscribe(editor_form_topic(form_key))
end
@doc """
Broadcasts a form state change to all subscribers.
Options:
- `:source` - The source identifier to prevent self-echoing
"""
def broadcast_editor_form_change(form_key, payload, opts \\ []) do
Manager.broadcast(
editor_form_topic(form_key),
{:editor_form_change, form_key, payload, Keyword.get(opts, :source)}
)
end
@doc """
Broadcasts a sync request for new joiners to get current state.
"""
def broadcast_editor_sync_request(form_key, requester_socket_id) do
Manager.broadcast(
editor_form_topic(form_key),
{:editor_sync_request, form_key, requester_socket_id}
)
end
@doc """
Broadcasts a sync response with current form state.
"""
def broadcast_editor_sync_response(form_key, requester_socket_id, state) do
Manager.broadcast(
editor_form_topic(form_key),
{:editor_sync_response, form_key, requester_socket_id, state}
)
end
# ============================================================================
# Cache Updates (for live admin UI updates)
# ============================================================================
@doc """
Returns the topic for cache updates for a specific group.
"""
def cache_topic(group_slug) do
"#{@topic_prefix}:#{group_slug}:cache"
end
@doc """
Subscribes the current process to cache updates for a group.
"""
def subscribe_to_cache(group_slug) do
Manager.subscribe(cache_topic(group_slug))
end
@doc """
Unsubscribes the current process from cache updates for a group.
"""
def unsubscribe_from_cache(group_slug) do
Manager.unsubscribe(cache_topic(group_slug))
end
@doc """
Broadcasts that the cache state has changed (cache regenerated, memory loaded, etc).
"""
def broadcast_cache_changed(group_slug) do
Manager.broadcast(cache_topic(group_slug), {:cache_changed, group_slug})
end
# ============================================================================
# AI Translation Progress
# ============================================================================
@doc """
Broadcasts that AI translation has started.
Sent to both posts_topic (for group listing) and post_translations_topic (for editor).
"""
def broadcast_translation_started(group_slug, post_slug, target_languages) do
payload = {:translation_started, group_slug, post_slug, target_languages}
# Broadcast to group listing
Manager.broadcast(
posts_topic(group_slug),
{:translation_started, post_slug, length(target_languages)}
)
# Broadcast to editor (more detailed info)
Manager.broadcast(post_translations_topic(group_slug, post_slug), payload)
end
@doc """
Broadcasts AI translation progress (after each language completes).
Sent to both posts_topic (for group listing) and post_translations_topic (for editor).
"""
def broadcast_translation_progress(group_slug, post_slug, completed, total, last_language) do
# Broadcast to group listing
Manager.broadcast(
posts_topic(group_slug),
{:translation_progress, post_slug, completed, total}
)
# Broadcast to editor (more detailed info)
Manager.broadcast(
post_translations_topic(group_slug, post_slug),
{:translation_progress, group_slug, post_slug, completed, total, last_language}
)
end
@doc """
Broadcasts that AI translation has completed (success or partial failure).
Sent to both posts_topic (for group listing) and post_translations_topic (for editor).
"""
def broadcast_translation_completed(group_slug, post_slug, results) do
# Broadcast to group listing
Manager.broadcast(
posts_topic(group_slug),
{:translation_completed, post_slug, results}
)
# Broadcast to editor
Manager.broadcast(
post_translations_topic(group_slug, post_slug),
{:translation_completed, group_slug, post_slug, results}
)
end
# ============================================================================
# Editor Presence for Group Listing
# ============================================================================
@doc """
Returns the global topic for editor activity across a group.
Used by group listing to show who's editing what.
"""
def group_editors_topic(group_slug) do
"#{@topic_prefix}:#{group_slug}:editors"
end
@doc """
Subscribes to editor activity for a group (used by group listing).
"""
def subscribe_to_group_editors(group_slug) do
Manager.subscribe(group_editors_topic(group_slug))
end
@doc """
Unsubscribes from editor activity for a group.
"""
def unsubscribe_from_group_editors(group_slug) do
Manager.unsubscribe(group_editors_topic(group_slug))
end
@doc """
Broadcasts that a user started editing a post.
"""
def broadcast_editor_joined(group_slug, post_slug, user_info) do
Manager.broadcast(
group_editors_topic(group_slug),
{:editor_joined, post_slug, user_info}
)
end
@doc """
Broadcasts that a user stopped editing a post.
"""
def broadcast_editor_left(group_slug, post_slug, user_info) do
Manager.broadcast(
group_editors_topic(group_slug),
{:editor_left, post_slug, user_info}
)
end
# ============================================================================
# Form Key Helpers
# ============================================================================
@doc """
Generates a form key for a post being edited.
The form key includes the language to allow concurrent editing of different
translations of the same post.
## Examples
generate_form_key("blog", %{path: "blog/my-post/v1/en"})
# => "blog:blog/my-post/v1/en"
generate_form_key("blog", %{slug: "my-post", language: "en"})
# => "blog:my-post:en"
generate_form_key("blog", %{slug: "my-post", language: "en"}, :new)
# => "blog:new:en"
"""
def generate_form_key(group_slug, post, mode \\ :edit)
# UUID-based form key (preferred for DB posts)
def generate_form_key(group_slug, %{uuid: uuid, language: lang}, :edit)
when is_binary(uuid) and is_binary(lang) do
"#{group_slug}:#{uuid}:#{lang}"
end
# Path already includes language (e.g., "blog/my-post/v1/en")
def generate_form_key(group_slug, %{path: path}, :edit) when is_binary(path) do
"#{group_slug}:#{path}"
end
# Slug mode - include language for per-language locking
def generate_form_key(group_slug, %{slug: slug, language: lang}, :edit)
when is_binary(slug) and is_binary(lang) do
"#{group_slug}:#{slug}:#{lang}"
end
# Fallback for slug without language (shouldn't happen in practice)
def generate_form_key(group_slug, %{slug: slug}, :edit) when is_binary(slug) do
"#{group_slug}:#{slug}"
end
def generate_form_key(group_slug, %{language: lang}, :new) do
"#{group_slug}:new:#{lang}"
end
def generate_form_key(group_slug, _post, :new) do
"#{group_slug}:new"
end
def generate_form_key(group_slug, _, _) do
"#{group_slug}:unknown"
end
# ============================================================================
# Primary Language Migration Progress
# ============================================================================
@doc """
Broadcasts that primary language migration has completed.
"""
def broadcast_primary_language_migration_completed(
group_slug,
success_count,
error_count,
primary_language
) do
Manager.broadcast(
posts_topic(group_slug),
{:primary_language_migration_completed, group_slug, success_count, error_count,
primary_language}
)
end
end