Current section

Files

Jump to
phoenix_kit_comments lib phoenix_kit_comments.ex
Raw

lib/phoenix_kit_comments.ex

defmodule PhoenixKitComments do
@moduledoc """
Standalone, resource-agnostic comments module.
Provides polymorphic commenting for any resource type (posts, entities, tickets, etc.)
with unlimited threading, likes/dislikes, and moderation support.
## Architecture
Comments are linked to resources via `resource_type` (string) + `resource_uuid` (UUID).
No foreign key constraints on the resource side — any module can use comments.
## Resource Handler Callbacks
Modules that consume comments can register handlers to receive notifications
when comments are created or deleted. Configure in your app:
config :phoenix_kit, :comment_resource_handlers, %{
"post" => PhoenixKitPosts
}
Handler modules should implement `on_comment_created/3` and `on_comment_deleted/3`.
## Core Functions
### System Management
- `enabled?/0` - Check if Comments module is enabled
- `enable_system/0` - Enable the Comments module
- `disable_system/0` - Disable the Comments module
- `get_config/0` - Get module configuration with statistics
### Comment CRUD
- `create_comment/4` - Create a comment on a resource
- `update_comment/2` - Update a comment
- `delete_comment/1` - Delete a comment
- `get_comment/2`, `get_comment!/2` - Get by ID
- `list_comments/3` - Flat list for a resource
- `get_comment_tree/2` - Nested tree for a resource
- `count_comments/3` - Count comments for a resource
### Moderation
- `approve_comment/1` - Set status to published
- `hide_comment/1` - Set status to hidden
- `bulk_update_status/2` - Bulk status changes
- `list_all_comments/1` - Cross-resource listing with filters
- `comment_stats/0` - Aggregate statistics
### Like/Dislike
- `like_comment/2`, `unlike_comment/2`, `comment_liked_by?/2`
- `dislike_comment/2`, `undislike_comment/2`, `comment_disliked_by?/2`
"""
use PhoenixKit.Module
import Ecto.Query, warn: false
require Logger
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
alias PhoenixKit.Utils.UUID, as: UUIDUtils
alias PhoenixKitComments.Comment
alias PhoenixKitComments.CommentDislike
alias PhoenixKitComments.CommentLike
alias PhoenixKitComments.CommentMedia
# ============================================================================
# Module Status
# ============================================================================
@impl PhoenixKit.Module
@doc "Checks if the Comments module is enabled."
def enabled? do
Settings.get_boolean_setting("comments_enabled", false)
rescue
_ -> false
end
@impl PhoenixKit.Module
@doc "Enables the Comments module."
def enable_system do
Settings.update_boolean_setting_with_module("comments_enabled", true, "comments")
end
@impl PhoenixKit.Module
@doc "Disables the Comments module."
def disable_system do
Settings.update_boolean_setting_with_module("comments_enabled", false, "comments")
end
@impl PhoenixKit.Module
@doc "Gets the Comments module configuration with statistics."
def get_config do
%{
enabled: enabled?(),
total_comments: count_all_comments(),
published_comments: count_all_comments(status: "published"),
pending_comments: count_all_comments(status: "pending"),
moderation_enabled: Settings.get_boolean_setting("comments_moderation", false),
max_depth: get_max_depth(),
max_length: get_max_length()
}
end
@doc "Returns the configured maximum comment depth."
def get_max_depth do
case Integer.parse(Settings.get_setting("comments_max_depth", "10")) do
{n, _} -> n
:error -> 10
end
end
@doc "Returns the configured maximum comment length."
def get_max_length do
case Integer.parse(Settings.get_setting("comments_max_length", "10000")) do
{n, _} -> n
:error -> 10_000
end
end
# ============================================================================
# Attachments configuration
# ============================================================================
@doc "Returns `true` when comment attachments are enabled in settings."
@spec attachments_enabled?() :: boolean()
def attachments_enabled? do
Settings.get_boolean_setting("comments_attachments_enabled", false)
rescue
_ -> false
end
@doc "Returns the per-comment attachment count cap (default 4)."
@spec get_max_attachments() :: pos_integer()
def get_max_attachments do
case Integer.parse(Settings.get_setting("comments_max_attachments", "4")) do
{n, _} when n > 0 -> n
_ -> 4
end
rescue
_ -> 4
end
@doc """
Returns the per-attachment size cap in MB.
Clamped against the global `storage_max_upload_size_mb` so an admin
can't accidentally let comment uploads exceed the platform cap.
"""
@spec get_max_attachment_size_mb() :: pos_integer()
def get_max_attachment_size_mb do
comment_cap = parse_size_setting("comments_attachment_max_size_mb", 20)
global_cap = parse_size_setting("storage_max_upload_size_mb", 500)
min(comment_cap, global_cap)
end
defp parse_size_setting(key, default) do
case Integer.parse(Settings.get_setting(key, Integer.to_string(default))) do
{n, _} when n > 0 -> n
_ -> default
end
rescue
_ -> default
end
# ============================================================================
# Giphy Integration
# ============================================================================
@type gif_map :: %{
required(String.t()) => String.t() | integer() | nil
}
@doc """
Returns `true` when the Giphy picker should be shown in the comment form.
Requires both the `comments_giphy_enabled` toggle and a non-empty API key.
"""
@spec giphy_enabled?() :: boolean()
def giphy_enabled? do
Settings.get_boolean_setting("comments_giphy_enabled", false) and
get_giphy_api_key() != ""
end
@doc "Returns the configured Giphy API key (empty string when unset)."
@spec get_giphy_api_key() :: String.t()
def get_giphy_api_key, do: Settings.get_setting("comments_giphy_api_key", "")
@doc "Returns the configured Giphy content rating (g/pg/pg-13/r)."
@spec get_giphy_rating() :: String.t()
def get_giphy_rating, do: Settings.get_setting("comments_giphy_rating", "g")
@doc """
Searches Giphy for GIFs matching the query, using the configured API key and rating.
Returns `{:ok, [gif_map]}` on success or `{:error, reason}` on failure. Each `gif_map`
has string keys: `"id"`, `"url"` (original image), `"preview_url"` (thumbnail),
`"width"`, `"height"`.
"""
@spec search_giphy(String.t(), keyword()) ::
{:ok, [gif_map()]} | {:error, atom()}
def search_giphy(query, opts \\ []) when is_binary(query) do
case String.trim(query) do
"" ->
{:ok, []}
trimmed ->
case get_giphy_api_key() do
"" ->
{:error, :missing_api_key}
api_key ->
rating = get_giphy_rating()
limit = Keyword.get(opts, :limit, 24)
try do
case GiphyApi.search(trimmed,
api_key: api_key,
rating: rating,
limit: limit
) do
{:ok, results} ->
{:ok, results |> Enum.map(&normalize_giphy_gif/1) |> Enum.reject(&is_nil/1)}
{:error, _} = err ->
err
end
rescue
e ->
Logger.warning("Giphy search failed: #{inspect(e)}")
{:error, :giphy_error}
end
end
end
end
defp normalize_giphy_gif(%GiphyApi.Gif{} = gif) do
if giphy_host?(gif.original_url) and giphy_host?(gif.preview_url) do
%{
"id" => gif.id,
"url" => gif.original_url,
"preview_url" => gif.preview_url,
"width" => gif.original_width,
"height" => gif.original_height
}
end
end
defp giphy_host?(url) when is_binary(url) do
case URI.parse(url) do
%URI{host: host} when is_binary(host) ->
String.ends_with?(host, ".giphy.com") or host == "giphy.com"
_ ->
false
end
end
defp giphy_host?(_), do: false
# ============================================================================
# Module Behaviour Callbacks
# ============================================================================
@impl PhoenixKit.Module
def module_key, do: "comments"
@impl PhoenixKit.Module
def module_name, do: "Comments"
@impl PhoenixKit.Module
def version, do: "0.2.0"
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "comments",
label: "Comments",
icon: "hero-chat-bubble-left-right",
description: "Comment moderation, threading, and reactions across all content types"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_comments,
label: "Comments",
icon: "hero-chat-bubble-left-right",
path: "comments",
priority: 590,
level: :admin,
permission: "comments",
match: :prefix,
group: :admin_modules,
live_view: {PhoenixKitComments.Web.Index, :index}
)
]
end
@impl PhoenixKit.Module
def settings_tabs do
[
Tab.new!(
id: :admin_settings_comments,
label: "Comments",
icon: "hero-chat-bubble-left-right",
path: "comments",
priority: 924,
level: :admin,
parent: :admin_settings,
permission: "comments",
live_view: {PhoenixKitComments.Web.Settings, :settings}
)
]
end
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_comments]
# ============================================================================
# Comment CRUD
# ============================================================================
@doc """
Creates a comment on a resource.
Automatically calculates depth from parent. Invokes resource handler callback
if configured.
## Parameters
- `resource_type` - Type of resource (e.g., "post")
- `resource_uuid` - UUID of the resource
- `user_uuid` - UUID of commenter
- `attrs` - Comment attributes (content, parent_uuid, metadata, etc.).
May include `:attachment_file_uuids` — a list of
`PhoenixKit.Modules.Storage.File` UUIDs to attach to the new comment
in display order. Comment insert + attachments run in one
transaction; any attach failure rolls back the comment too.
"""
def create_comment(resource_type, resource_uuid, user_uuid, attrs) when is_binary(user_uuid) do
if UUIDUtils.valid?(user_uuid) do
do_create_comment(resource_type, resource_uuid, user_uuid, attrs)
else
{:error, :invalid_user_uuid}
end
end
@doc """
Validates a prospective comment before any uploads are consumed.
Use this in form handlers ahead of `Phoenix.LiveView.consume_uploaded_entries/3`
so that depth / length / attachment-cap failures don't leak files into
the storage backend. Accepts the same attrs as `create_comment/4`
except `:attachment_file_uuids` — pass `entry_count` instead, which is
how many uploads are currently staged on the LiveView.
Returns `:ok` or `{:error, reason}` with the same reasons
`create_comment/4` would surface (`:invalid_user_uuid`,
`:max_depth_exceeded`, `:content_too_long`, `:attachments_disabled`,
`:too_many_attachments`, `:empty_comment`).
"""
@spec precheck_create(String.t(), term(), String.t(), map(), non_neg_integer()) ::
:ok | {:error, atom()}
def precheck_create(resource_type, resource_uuid, user_uuid, attrs, entry_count \\ 0)
when is_binary(user_uuid) and is_integer(entry_count) and entry_count >= 0 do
if UUIDUtils.valid?(user_uuid) do
prepared = prepare_create_attrs(resource_type, resource_uuid, user_uuid, attrs)
run_cheap_validators(prepared, entry_count)
else
{:error, :invalid_user_uuid}
end
end
defp do_create_comment(resource_type, resource_uuid, user_uuid, attrs) do
{file_uuids, attrs} = Map.pop(attrs, :attachment_file_uuids, [])
file_uuids = List.wrap(file_uuids)
attrs = prepare_create_attrs(resource_type, resource_uuid, user_uuid, attrs)
with :ok <- run_cheap_validators(attrs, length(file_uuids)),
:ok <- validate_file_uuid_format(file_uuids),
{:ok, comment} <- insert_comment_with_attachments(attrs, file_uuids) do
notify_resource_handler(:on_comment_created, resource_type, resource_uuid, comment)
{:ok, comment}
end
end
defp prepare_create_attrs(resource_type, resource_uuid, user_uuid, attrs) do
attrs
|> Map.put(:resource_type, resource_type)
|> Map.put(:resource_uuid, resource_uuid)
|> Map.put(:user_uuid, user_uuid)
|> maybe_calculate_depth()
|> maybe_set_initial_status()
end
defp run_cheap_validators(attrs, file_count) do
with :ok <- validate_depth(attrs),
:ok <- validate_content_length(attrs),
:ok <- validate_attachment_count(file_count) do
validate_has_body(attrs, file_count)
end
end
defp insert_comment_with_attachments(attrs, []) do
%Comment{} |> Comment.changeset(attrs, has_media: false) |> repo().insert()
end
defp insert_comment_with_attachments(attrs, file_uuids) do
repo().transaction(fn ->
with {:ok, comment} <-
%Comment{} |> Comment.changeset(attrs, has_media: true) |> repo().insert(),
:ok <- attach_files(comment.uuid, file_uuids) do
repo().preload(comment, media: :file)
else
{:error, reason} -> repo().rollback(reason)
end
end)
end
defp attach_files(comment_uuid, file_uuids) do
file_uuids
|> Enum.with_index(1)
|> Enum.reduce_while(:ok, fn {file_uuid, position}, _acc ->
case attach_media(comment_uuid, file_uuid, position: position) do
{:ok, _} -> {:cont, :ok}
{:error, changeset} -> {:halt, {:error, changeset}}
end
end)
end
# Cap + feature-flag checks that don't need the file UUIDs themselves.
# Run by `precheck_create/5` before the LiveView consumes uploads, and
# again inside `create_comment/4` so non-LiveView callers stay covered.
defp validate_attachment_count(0), do: :ok
defp validate_attachment_count(count) when is_integer(count) and count > 0 do
cond do
not attachments_enabled?() -> {:error, :attachments_disabled}
count > get_max_attachments() -> {:error, :too_many_attachments}
true -> :ok
end
end
defp validate_file_uuid_format([]), do: :ok
defp validate_file_uuid_format(file_uuids) when is_list(file_uuids) do
if Enum.any?(file_uuids, &(not UUIDUtils.valid?(to_string(&1)))) do
{:error, :invalid_file_uuid}
else
:ok
end
end
defp validate_has_body(attrs, file_count) do
cond do
has_content?(attrs) -> :ok
has_giphy?(attrs) -> :ok
file_count > 0 -> :ok
true -> {:error, :empty_comment}
end
end
defp has_content?(attrs) do
content = attrs[:content] || attrs["content"] || ""
String.trim(to_string(content)) != ""
end
defp has_giphy?(attrs) do
metadata = attrs[:metadata] || attrs["metadata"] || %{}
is_map(metadata) and
match?(%{"url" => u} when is_binary(u) and u != "", metadata["giphy"])
end
@doc """
Updates a comment.
## Parameters
- `comment` - Comment to update
- `attrs` - Attributes to update (content, status)
"""
def update_comment(%Comment{} = comment, attrs) do
# Preload :media so the changeset can infer "has media" when content
# is being changed. Status-only updates skip the content-or-media
# check entirely (see `Comment.changeset/3`), so this is a no-op on
# moderation paths if `:media` is already loaded; but we ensure it
# for content edits because the caller may pass a bare struct from
# `get_comment/1`.
comment
|> ensure_media_loaded()
|> Comment.changeset(attrs)
|> repo().update()
end
defp ensure_media_loaded(%Comment{media: %Ecto.Association.NotLoaded{}} = comment) do
repo().preload(comment, :media)
end
defp ensure_media_loaded(%Comment{} = comment), do: comment
@doc """
Soft-deletes a comment by setting its status to "deleted".
Invokes resource handler callback if configured.
"""
def delete_comment(%Comment{} = comment) do
case update_comment(comment, %{status: "deleted"}) do
{:ok, deleted} ->
notify_resource_handler(
:on_comment_deleted,
comment.resource_type,
comment.resource_uuid,
deleted
)
{:ok, deleted}
error ->
error
end
end
@doc """
Gets a single comment by ID with optional preloads.
Returns `nil` if not found.
"""
def get_comment(id, opts \\ []) do
preloads = Keyword.get(opts, :preload, [])
case repo().get(Comment, id) do
nil -> nil
comment -> repo().preload(comment, preloads)
end
end
@doc """
Gets a single comment by ID with optional preloads.
Raises `Ecto.NoResultsError` if not found.
"""
def get_comment!(id, opts \\ []) do
preloads = Keyword.get(opts, :preload, [])
Comment
|> repo().get!(id)
|> repo().preload(preloads)
end
@doc """
Gets nested comment tree for a resource.
Returns all published comments organized in a tree structure. Deleted
comments with published descendants are preserved as `[removed]`
placeholders so reply chains stay attached; deleted leaves are pruned.
"""
def get_comment_tree(resource_type, resource_uuid) do
comments =
from(c in Comment,
where:
c.resource_type == ^resource_type and
c.resource_uuid == ^resource_uuid and
c.status in ["published", "deleted"],
order_by: [asc: c.inserted_at],
preload: [:user, media: :file]
)
|> repo().all()
build_comment_tree(comments)
end
@doc """
Lists comments for a resource (flat list).
Soft-deleted comments are excluded by default. Pass `include_deleted: true`
(or an explicit `status:`) for admin callers that need them.
## Options
- `:preload` - Associations to preload
- `:status` - Filter by status
- `:include_deleted` - Include `status == "deleted"` rows (default: false)
"""
def list_comments(resource_type, resource_uuid, opts \\ []) do
preloads = Keyword.get(opts, :preload, [])
status = Keyword.get(opts, :status)
include_deleted = Keyword.get(opts, :include_deleted, false)
query =
from(c in Comment,
where: c.resource_type == ^resource_type and c.resource_uuid == ^resource_uuid,
order_by: [asc: c.inserted_at]
)
query = apply_status_filter(query, status, include_deleted)
query
|> repo().all()
|> repo().preload(preloads)
end
@doc """
Counts comments for a resource.
Mirrors `list_comments/3`: deleted rows are excluded unless `:status` is
set explicitly or `include_deleted: true` is passed.
"""
def count_comments(resource_type, resource_uuid, opts \\ []) do
status = Keyword.get(opts, :status)
include_deleted = Keyword.get(opts, :include_deleted, false)
query =
from(c in Comment,
where: c.resource_type == ^resource_type and c.resource_uuid == ^resource_uuid
)
query = apply_status_filter(query, status, include_deleted)
repo().aggregate(query, :count)
rescue
_ -> 0
end
defp apply_status_filter(query, nil, false), do: where(query, [c], c.status != "deleted")
defp apply_status_filter(query, nil, true), do: query
defp apply_status_filter(query, status, _), do: where(query, [c], c.status == ^status)
# ============================================================================
# Moderation
# ============================================================================
@doc "Sets a comment's status to published."
def approve_comment(%Comment{} = comment) do
update_comment(comment, %{status: "published"})
end
@doc "Sets a comment's status to hidden."
def hide_comment(%Comment{} = comment) do
update_comment(comment, %{status: "hidden"})
end
@doc """
Bulk-updates status for multiple comment UUIDs.
Routes through `update_comment/2` (and `delete_comment/1` for the
`"deleted"` case) so resource-handler callbacks fire per row. Returns
`{ok_count, error_count}`.
"""
def bulk_update_status(comment_uuids, status)
when is_list(comment_uuids) and status in ["published", "hidden", "deleted", "pending"] do
comments =
from(c in Comment, where: c.uuid in ^comment_uuids)
|> repo().all()
Enum.reduce(comments, {0, 0}, fn comment, {ok, err} ->
result =
case status do
"deleted" -> delete_comment(comment)
_ -> update_comment(comment, %{status: status})
end
case result do
{:ok, _} -> {ok + 1, err}
_ -> {ok, err + 1}
end
end)
end
@doc """
Lists all comments across all resource types with filters.
## Options
- `:resource_type` - Filter by resource type
- `:status` - Filter by status
- `:user_uuid` - Filter by user
- `:search` - Search in content
- `:page` - Page number (default: 1)
- `:per_page` - Items per page (default: 20)
"""
def list_all_comments(opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 20)
resource_type = Keyword.get(opts, :resource_type)
status = Keyword.get(opts, :status)
user_uuid = Keyword.get(opts, :user_uuid)
search = Keyword.get(opts, :search)
query =
from(c in Comment,
order_by: [desc: c.inserted_at],
preload: [:user, :parent]
)
query =
if resource_type, do: where(query, [c], c.resource_type == ^resource_type), else: query
query = if status, do: where(query, [c], c.status == ^status), else: query
query = maybe_filter_by_user(query, user_uuid)
query =
if search && String.trim(search) != "" do
pattern = "%#{escape_like_pattern(search)}%"
where(query, [c], ilike(c.content, ^pattern))
else
query
end
total = repo().aggregate(query, :count)
comments =
query
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> repo().all()
%{
comments: comments,
total: total,
page: page,
per_page: per_page,
total_pages: ceil(total / per_page)
}
end
@doc "Returns distinct resource types that have comments."
def list_resource_types do
from(c in Comment, distinct: true, select: c.resource_type, order_by: c.resource_type)
|> repo().all()
rescue
_ -> []
end
@doc "Returns comment counts grouped by resource type."
def count_comments_by_type do
from(c in Comment,
group_by: c.resource_type,
select: {c.resource_type, count(c.uuid)}
)
|> repo().all()
|> Map.new()
rescue
e ->
Logger.warning("Failed to load comment counts by type: #{inspect(e)}")
%{}
end
@doc """
Returns distinct metadata keys grouped by resource type.
Queries the JSONB `metadata` column for all keys in use, e.g.:
%{"manga_annotation" => ["chapter", "page", "slug", "source"],
"post" => ["category"]}
"""
def list_metadata_keys_by_type do
from(c in Comment,
where: c.metadata != ^%{},
select: {c.resource_type, fragment("jsonb_object_keys(?)", c.metadata)},
distinct: true
)
|> repo().all()
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Map.new(fn {type, keys} -> {type, Enum.sort(keys)} end)
rescue
e ->
Logger.warning("Failed to load metadata keys by type: #{inspect(e)}")
%{}
end
@doc "Returns aggregate statistics for all comments."
def comment_stats do
%{
total: count_all_comments(),
published: count_all_comments(status: "published"),
pending: count_all_comments(status: "pending"),
hidden: count_all_comments(status: "hidden"),
deleted: count_all_comments(status: "deleted")
}
end
# ============================================================================
# Comment Attachments
# ============================================================================
@doc """
Attaches an uploaded file to a comment.
`position` defaults to 1; the caller is responsible for assigning
non-colliding positions (the DB has a unique constraint on
`(comment_uuid, position)`).
"""
@spec attach_media(UUIDv7.t(), UUIDv7.t(), keyword()) ::
{:ok, CommentMedia.t()} | {:error, Ecto.Changeset.t()}
def attach_media(comment_uuid, file_uuid, opts \\ []) do
position = Keyword.get(opts, :position, 1)
caption = Keyword.get(opts, :caption)
%CommentMedia{}
|> CommentMedia.changeset(%{
comment_uuid: comment_uuid,
file_uuid: file_uuid,
position: position,
caption: caption
})
|> repo().insert()
end
@doc "Detaches a media row by `(comment_uuid, file_uuid)`."
def detach_media(comment_uuid, file_uuid) do
case repo().get_by(CommentMedia, comment_uuid: comment_uuid, file_uuid: file_uuid) do
nil -> {:error, :not_found}
media -> repo().delete(media)
end
end
@doc "Detaches a media row by its own uuid."
def detach_media_by_uuid(media_uuid) do
case repo().get(CommentMedia, media_uuid) do
nil -> {:error, :not_found}
media -> repo().delete(media)
end
end
@doc "Lists media for a comment, ordered by `position`."
def list_comment_media(comment_uuid, opts \\ []) do
preloads = Keyword.get(opts, :preload, [:file])
from(m in CommentMedia,
where: m.comment_uuid == ^comment_uuid,
order_by: [asc: m.position]
)
|> repo().all()
|> repo().preload(preloads)
end
# ============================================================================
# Resource Path Templates
# ============================================================================
@doc """
Gets configured resource templates (path + optional display title).
Returns a map of `resource_type => config`, where config is either:
- A plain string (legacy path-only format)
- A map with `"path"` and optional `"title"` keys
## Examples
%{"shoes" => "/order/shoes/:uuid"}
%{"shoes" => %{"path" => "/order/shoes/:uuid", "title" => ":metadata.name"}}
"""
def get_resource_path_templates do
Settings.get_json_setting("comment_resource_paths", %{})
rescue
e ->
Logger.warning("Failed to load resource path templates: #{inspect(e)}")
%{}
end
@doc """
Updates resource templates for resource types.
Accepts both legacy string values and new map values with `"path"` and `"title"` keys.
"""
def update_resource_path_templates(templates) when is_map(templates) do
Settings.update_json_setting("comment_resource_paths", templates)
end
# ============================================================================
# Resource Resolution (for admin UI)
# ============================================================================
@doc """
Resolves resource context (title and admin path) for a list of comments.
Returns a map of `{resource_type, resource_uuid} => %{title: ..., path: ...}`
by delegating to registered `comment_resource_handlers` that implement
`resolve_comment_resources/1`.
"""
def resolve_resource_context(comments) do
comments
|> Enum.group_by(& &1.resource_type)
|> Enum.reduce(%{}, fn {resource_type, type_comments}, acc ->
resolved = resolve_for_type(resource_type, type_comments)
Enum.reduce(resolved, acc, fn {id, info}, inner ->
Map.put(inner, {resource_type, id}, info)
end)
end)
end
defp resource_handlers do
configured = Application.get_env(:phoenix_kit, :comment_resource_handlers, %{})
Map.merge(default_resource_handlers(), configured)
end
defp default_resource_handlers do
handlers = %{}
handlers =
if Code.ensure_loaded?(PhoenixKitPosts),
do: Map.put(handlers, "post", PhoenixKitPosts),
else: handlers
handlers
end
defp resolve_for_type(resource_type, comments) do
resource_uuids = comments |> Enum.map(& &1.resource_uuid) |> Enum.uniq()
case resolve_via_handler(resource_type, resource_uuids) do
result when map_size(result) > 0 ->
Map.new(result, fn {id, info} -> {id, Map.put(info, :prefixed, true)} end)
_ ->
resolve_via_path_template(resource_type, comments)
end
rescue
e ->
Logger.warning("Comment resource resolver error: #{inspect(e)}")
%{}
end
defp resolve_via_handler(resource_type, resource_uuids) do
handlers = resource_handlers()
case Map.get(handlers, resource_type) do
nil ->
%{}
mod ->
if Code.ensure_loaded?(mod) and function_exported?(mod, :resolve_comment_resources, 1) do
mod.resolve_comment_resources(resource_uuids)
else
%{}
end
end
end
defp resolve_via_path_template(resource_type, comments) do
templates = get_resource_path_templates()
case Map.get(templates, resource_type) do
nil ->
%{}
config ->
path_template = path_from_config(config)
title_template = title_from_config(config)
Map.new(comments, fn comment ->
metadata = comment.metadata || %{}
path = apply_path_template(path_template, comment.resource_uuid, metadata)
title = resolve_title(title_template, resource_type, comment, metadata)
full_title = resolve_full_title(title_template, resource_type, comment, metadata)
{comment.resource_uuid,
%{title: title, full_title: full_title, path: path, prefixed: false}}
end)
end
end
defp resolve_title(nil, resource_type, comment, _metadata) do
short_id = comment.resource_uuid |> to_string() |> String.slice(0..7)
"#{resource_type} #{short_id}..."
end
defp resolve_title(title_template, _resource_type, comment, metadata) do
apply_title_template(title_template, comment.resource_uuid, metadata)
end
defp resolve_full_title(nil, resource_type, comment, _metadata) do
"#{resource_type} #{comment.resource_uuid}"
end
defp resolve_full_title(title_template, _resource_type, comment, metadata) do
title_template
|> replace_metadata_placeholders(metadata)
|> String.replace(":uuid", to_string(comment.resource_uuid))
end
defp path_from_config(config) when is_binary(config), do: config
defp path_from_config(%{"path" => path}), do: path
defp path_from_config(_), do: ""
defp title_from_config(config) when is_binary(config), do: nil
defp title_from_config(%{"title" => ""}), do: nil
defp title_from_config(%{"title" => title}), do: title
defp title_from_config(_), do: nil
defp apply_path_template(template, resource_uuid, metadata) do
template
|> replace_metadata_url_placeholders(metadata)
|> String.replace(":prefix", prefix_value())
|> String.replace(":uuid", url_encode(to_string(resource_uuid)))
end
defp apply_title_template(template, resource_uuid, metadata) do
template
|> replace_metadata_truncated(metadata)
|> String.replace(":uuid", truncate_value(to_string(resource_uuid)))
end
defp prefix_value do
prefix = Routes.url_prefix()
if prefix == "/", do: "", else: prefix
end
defp replace_metadata_placeholders(template, metadata) do
Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key ->
metadata |> Map.get(key, "") |> to_string()
end)
end
defp replace_metadata_url_placeholders(template, metadata) do
Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key ->
metadata |> Map.get(key, "") |> to_string() |> url_encode()
end)
end
defp replace_metadata_truncated(template, metadata) do
Regex.replace(~r/:metadata\.(\w+)/, template, fn _match, key ->
metadata |> Map.get(key, "") |> to_string() |> truncate_value()
end)
end
defp url_encode(value), do: URI.encode(value, &URI.char_unreserved?/1)
@metadata_max_display_length 15
defp truncate_value(value) do
if String.length(value) <= @metadata_max_display_length do
value
else
String.slice(value, 0, @metadata_max_display_length) <> "..."
end
end
# ============================================================================
# Like Operations
# ============================================================================
@doc """
User likes a comment. Removes any existing dislike first.
Returns `{:ok, :liked}` when a new like row was created, or
`{:ok, :already_liked}` when the user had already liked the comment.
"""
def like_comment(comment_uuid, user_uuid) when is_binary(user_uuid) do
repo().transaction(fn ->
maybe_remove_reaction(CommentDislike, comment_uuid, user_uuid, :dislike_count)
if insert_reaction(CommentLike, comment_uuid, user_uuid, :like_count) do
:liked
else
:already_liked
end
end)
end
@doc """
User unlikes a comment. Deletes the like row and decrements the counter
atomically. Returns `{:ok, :unliked}` or `{:error, :not_found}`.
"""
def unlike_comment(comment_uuid, user_uuid) when is_binary(user_uuid) do
if maybe_remove_reaction(CommentLike, comment_uuid, user_uuid, :like_count) do
{:ok, :unliked}
else
{:error, :not_found}
end
end
@doc "Checks if a user has liked a comment."
def comment_liked_by?(comment_uuid, user_uuid) when is_binary(user_uuid) do
repo().exists?(
from(l in CommentLike, where: l.comment_uuid == ^comment_uuid and l.user_uuid == ^user_uuid)
)
end
@doc "Lists all likes for a comment."
def list_comment_likes(comment_uuid, opts \\ []) do
preloads = Keyword.get(opts, :preload, [])
from(l in CommentLike,
where: l.comment_uuid == ^comment_uuid,
order_by: [desc: l.inserted_at]
)
|> repo().all()
|> repo().preload(preloads)
end
# ============================================================================
# Dislike Operations
# ============================================================================
@doc """
User dislikes a comment. Removes any existing like first.
Returns `{:ok, :disliked}` when a new dislike row was created, or
`{:ok, :already_disliked}` when the user had already disliked the comment.
"""
def dislike_comment(comment_uuid, user_uuid) when is_binary(user_uuid) do
repo().transaction(fn ->
maybe_remove_reaction(CommentLike, comment_uuid, user_uuid, :like_count)
if insert_reaction(CommentDislike, comment_uuid, user_uuid, :dislike_count) do
:disliked
else
:already_disliked
end
end)
end
@doc """
User removes dislike from a comment. Deletes the dislike row and
decrements the counter atomically. Returns `{:ok, :undisliked}` or
`{:error, :not_found}`.
"""
def undislike_comment(comment_uuid, user_uuid) when is_binary(user_uuid) do
if maybe_remove_reaction(CommentDislike, comment_uuid, user_uuid, :dislike_count) do
{:ok, :undisliked}
else
{:error, :not_found}
end
end
@doc "Checks if a user has disliked a comment."
def comment_disliked_by?(comment_uuid, user_uuid) when is_binary(user_uuid) do
repo().exists?(
from(d in CommentDislike,
where: d.comment_uuid == ^comment_uuid and d.user_uuid == ^user_uuid
)
)
end
@doc "Lists all dislikes for a comment."
def list_comment_dislikes(comment_uuid, opts \\ []) do
preloads = Keyword.get(opts, :preload, [])
from(d in CommentDislike,
where: d.comment_uuid == ^comment_uuid,
order_by: [desc: d.inserted_at]
)
|> repo().all()
|> repo().preload(preloads)
end
# ============================================================================
# Private Helpers
# ============================================================================
defp maybe_calculate_depth(attrs) do
case Map.get(attrs, :parent_uuid) do
nil ->
Map.put(attrs, :depth, 0)
parent_uuid ->
case repo().get(Comment, parent_uuid) do
nil -> Map.put(attrs, :depth, 0)
parent -> Map.put(attrs, :depth, (parent.depth || 0) + 1)
end
end
end
defp build_comment_tree(comments) do
children_by_parent = Enum.group_by(comments, & &1.parent_uuid)
children_by_parent
|> Map.get(nil, [])
|> Enum.map(&add_children(&1, children_by_parent))
|> Enum.reject(&empty_deleted?/1)
end
defp add_children(comment, children_by_parent) do
children =
children_by_parent
|> Map.get(comment.uuid, [])
|> Enum.map(&add_children(&1, children_by_parent))
|> Enum.reject(&empty_deleted?/1)
Map.put(comment, :children, children)
end
defp empty_deleted?(%{status: "deleted", children: []}), do: true
defp empty_deleted?(_), do: false
defp insert_reaction(schema, comment_uuid, user_uuid, counter_field) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
{count, _} =
repo().insert_all(
schema,
[
%{
uuid: UUIDv7.generate(),
comment_uuid: comment_uuid,
user_uuid: user_uuid,
inserted_at: now,
updated_at: now
}
],
on_conflict: :nothing,
conflict_target: [:comment_uuid, :user_uuid]
)
if count > 0 do
increment_comment_counter(comment_uuid, counter_field)
true
else
false
end
end
defp maybe_remove_reaction(schema, comment_uuid, user_uuid, counter_field) do
{count, _} =
from(r in schema,
where: r.comment_uuid == ^comment_uuid and r.user_uuid == ^user_uuid
)
|> repo().delete_all()
if count > 0 do
decrement_comment_counter(comment_uuid, counter_field)
true
else
false
end
end
defp increment_comment_counter(comment_uuid, :like_count) do
from(c in Comment, where: c.uuid == ^comment_uuid)
|> repo().update_all(inc: [like_count: 1])
end
defp increment_comment_counter(comment_uuid, :dislike_count) do
from(c in Comment, where: c.uuid == ^comment_uuid)
|> repo().update_all(inc: [dislike_count: 1])
end
defp decrement_comment_counter(comment_uuid, :like_count) do
from(c in Comment, where: c.uuid == ^comment_uuid and c.like_count > 0)
|> repo().update_all(inc: [like_count: -1])
end
defp decrement_comment_counter(comment_uuid, :dislike_count) do
from(c in Comment, where: c.uuid == ^comment_uuid and c.dislike_count > 0)
|> repo().update_all(inc: [dislike_count: -1])
end
defp count_all_comments(opts \\ []) do
status = Keyword.get(opts, :status)
query = from(c in Comment)
query = if status, do: where(query, [c], c.status == ^status), else: query
repo().aggregate(query, :count)
rescue
_ -> 0
end
defp maybe_filter_by_user(query, nil), do: query
defp maybe_filter_by_user(query, user_uuid) when is_binary(user_uuid) do
if UUIDUtils.valid?(user_uuid) do
where(query, [c], c.user_uuid == ^user_uuid)
else
query
end
end
defp maybe_set_initial_status(attrs) do
if Map.has_key?(attrs, :status) do
attrs
else
if Settings.get_boolean_setting("comments_moderation", false) do
Map.put(attrs, :status, "pending")
else
attrs
end
end
end
defp validate_depth(attrs) do
max = get_max_depth()
if (attrs[:depth] || 0) >= max do
{:error, :max_depth_exceeded}
else
:ok
end
end
defp validate_content_length(attrs) do
max = get_max_length()
content = attrs[:content] || attrs["content"] || ""
if String.length(content) > max do
{:error, :content_too_long}
else
:ok
end
end
defp escape_like_pattern(pattern) do
pattern
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp notify_resource_handler(callback, resource_type, resource_uuid, comment) do
handlers = resource_handlers()
case Map.get(handlers, resource_type) do
nil ->
:ok
handler_module ->
if Code.ensure_loaded?(handler_module) and
function_exported?(handler_module, callback, 3) do
apply(handler_module, callback, [resource_type, resource_uuid, comment])
else
:ok
end
end
rescue
error ->
Logger.warning("Comment resource handler error: #{inspect(error)}")
:ok
end
defp repo do
PhoenixKit.RepoHelper.repo()
end
end