# `PhoenixKitComments`
[🔗](https://github.com/BeamLabEU/phoenix_kit_comments/blob/v0.4.0/lib/phoenix_kit_comments.ex#L1)

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
    }

The contract is `PhoenixKitComments.ResourceHandler`. Adopting it is
optional and changes nothing at runtime, but it is worth doing: dispatch is
by `function_exported?/3`, so a misnamed or wrong-arity callback is
indistinguishable from one you chose not to write — nothing fires, and there
is no error anywhere to find it by. `@behaviour` turns that into a compile
warning at the point of the mistake.

Handler modules may implement any of these optional callbacks (each guarded
by `function_exported?/3`, so implement only what you need):

* `on_comment_created(resource_type, resource_uuid, comment)` — new comment
  (check `comment.parent_uuid` to distinguish a reply).
* `on_comment_deleted(resource_type, resource_uuid, comment)` — comment removed.
* `on_comment_liked(resource_type, resource_uuid, %{comment: comment, liker_uuid: uuid})`
* `on_comment_unliked(resource_type, resource_uuid, %{comment: comment, liker_uuid: uuid})`
* `on_comment_disliked(resource_type, resource_uuid, %{comment: comment, liker_uuid: uuid})`
* `on_comment_undisliked(resource_type, resource_uuid, %{comment: comment, liker_uuid: uuid})`

The reaction callbacks fire only when the reaction state actually changed
(`{:ok, :liked}` / `{:ok, :unliked}` …), never on `:already_liked` no-ops.
Self-action skipping (e.g. don't notify someone who liked their own comment)
is left to the host. The `liker_uuid` is in the payload because the comment
row carries the author, not the reacting user.

## 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/3` - 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`

# `gif_map`

```elixir
@type gif_map() :: %{required(String.t()) =&gt; String.t() | integer() | nil}
```

# `approve_comment`

Sets a comment's status to published.

# `attach_media`

```elixir
@spec attach_media(UUIDv7.t(), UUIDv7.t(), keyword()) ::
  {:ok, PhoenixKitComments.CommentMedia.t()} | {:error, Ecto.Changeset.t()}
```

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)`).

# `attachments_enabled?`

```elixir
@spec attachments_enabled?() :: boolean()
```

Returns `true` when comment attachments are enabled in settings.

# `bulk_update_status`

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}`.

# `comment_disliked_by?`

Checks if a user has disliked a comment.

# `comment_liked_by?`

Checks if a user has liked a comment.

# `comment_stats`

Returns aggregate statistics for all comments.

# `count_comments`

```elixir
@spec count_comments(String.t(), Ecto.UUID.t() | [Ecto.UUID.t()], keyword()) ::
  non_neg_integer() | %{optional(Ecto.UUID.t()) =&gt; non_neg_integer()}
```

Counts comments for a resource, or a batch of resources.

When `resource_uuid` is a single UUID, returns the integer count for that
resource. When a **list** of UUIDs is given, returns a `uuid => count` map
in a single grouped query — including a `0` entry for every requested UUID
with no comments, so callers can render every row uniformly without an
N+1 (see `count_comments/3` with a list below).

Mirrors `list_comments/3`: deleted rows are excluded unless `:status` is
set explicitly or `include_deleted: true` is passed.

## Examples

    iex> count_comments("order", order_uuid)
    3

    iex> count_comments("order", [uuid_a, uuid_b, uuid_c])
    %{uuid_a => 3, uuid_b => 0, uuid_c => 7}

# `count_comments_by_type`

Returns comment counts grouped by resource type.

# `count_replies`

```elixir
@spec count_replies(
  [Ecto.UUID.t()],
  keyword()
) :: %{optional(Ecto.UUID.t()) =&gt; non_neg_integer()}
```

Counts replies for a batch of parent comments, as a `parent_uuid => count`
map.

Mirrors the list form of `count_comments/3`: one grouped query, and a `0`
entry for every uuid asked about, so a thread list renders uniformly without
an N+1 or a hand-rolled correlated subquery.

    iex> count_replies([a, b, c])
    %{a => 2, b => 0, c => 5}

Deleted replies are excluded unless `:status` is set explicitly or
`include_deleted: true` is passed — the same rule the other reads follow.

# `create_comment`

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.

# `delete_comment`

Soft-deletes a comment by setting its status to "deleted".

Invokes resource handler callback if configured.

# `detach_media`

Detaches a media row by `(comment_uuid, file_uuid)`.

# `detach_media_by_uuid`

Detaches a media row by its own uuid.

# `disable_system`

Disables the Comments module.

# `dislike_comment`

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.

# `enable_system`

Enables the Comments module.

# `enabled?`

Checks if the Comments module is enabled.

# `get_comment`

Gets a single comment by ID with optional preloads.

Returns `nil` if not found.

# `get_comment!`

Gets a single comment by ID with optional preloads.

Raises `Ecto.NoResultsError` if not found.

# `get_comment_tree`

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.

# `get_config`

Gets the Comments module configuration with statistics.

# `get_giphy_api_key`

```elixir
@spec get_giphy_api_key() :: String.t()
```

Returns the configured Giphy API key (empty string when unset).

# `get_giphy_rating`

```elixir
@spec get_giphy_rating() :: String.t()
```

Returns the configured Giphy content rating (g/pg/pg-13/r).

# `get_max_attachment_size_mb`

```elixir
@spec get_max_attachment_size_mb() :: pos_integer()
```

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.

# `get_max_attachments`

```elixir
@spec get_max_attachments() :: pos_integer()
```

Returns the per-comment attachment count cap (default 4).

# `get_max_depth`

Returns the configured maximum comment depth.

# `get_max_length`

Returns the configured maximum comment length.

# `get_resource_path_templates`

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"}}

# `giphy_enabled?`

```elixir
@spec giphy_enabled?() :: boolean()
```

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.

# `hide_comment`

Sets a comment's status to hidden.

# `like_comment`

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.

# `list_all_comments`

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)

# `list_comment_dislikes`

Lists all dislikes for a comment.

# `list_comment_likes`

Lists all likes for a comment.

# `list_comment_media`

Lists media for a comment, ordered by `position`.

# `list_comments`

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)
- `:metadata` - Map of `metadata` keys that must match, compared as text

## Listing across a whole resource type

Pass `:any` as the resource uuid to drop the per-resource condition. This
exists because `resource_uuid` is a UUID column, and plenty of hosts key
their comments on something that isn't one — a `(source, slug, chapter)`
triple, say. Those hosts mint a throwaway uuid per comment and put the real
key in `metadata`, at which point every listing they actually want is "this
type, where metadata says X" — a query the API could not express, so they
dropped to schemaless SQL against the table and inherited its sharp edges
(uuid columns load as 16-byte binaries there, which fails string comparison
silently).

    # every comment of this type for one manga, whatever chapter
    list_comments("chapter", :any, metadata: %{"source" => "mangadex", "slug" => slug})

Metadata is compared with `->>`, i.e. as text, so match against the string
form of whatever was stored.

# `list_metadata_keys_by_type`

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"]}

# `list_resource_types`

Returns distinct resource types that have comments.

# `list_user_disliked_comment_uuids`

Lists comment UUIDs from `comment_uuids` disliked by `user_uuid`.

# `list_user_liked_comment_uuids`

Lists comment UUIDs from `comment_uuids` liked by `user_uuid`.

# `merge_metadata`

```elixir
@spec merge_metadata(String.t(), map(), map()) :: non_neg_integer()
```

Merges `patch` into the metadata of every comment of `resource_type` whose
metadata matches `match`. Returns the number of rows updated.

This is the rename case: a host keyed on a slug renames it, and every
comment carrying the old one has to follow. Doing that by hand means a raw
`metadata || jsonb_build_object(...)` UPDATE in the host, which is how a
schema this package owns ends up written to from outside it.

    iex> merge_metadata("chapter", %{"slug" => "old"}, %{"slug" => "new"})
    42

Matching is the same text comparison `list_comments/3` uses. An empty
`match` is refused rather than treated as "everything" — a typo that
rewrites every comment of a type is not a thing this should make easy.

# `precheck_create`

```elixir
@spec precheck_create(String.t(), term(), String.t(), map(), non_neg_integer()) ::
  :ok | {:error, atom()}
```

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`).

# `resolve_resource_context`

Resolves resource context (title and admin path) for a list of comments.

Returns a map of `{resource_type, resource_uuid} => %{title: ..., path: ...}`.
Delegates to `PhoenixKit.ResourceLinks` (core) — the same resolver drives both
this comments moderation admin and the Activity feed, off one set of handlers
and the shared `comment_resource_paths` templates.

# `restore_comment`

```elixir
@spec restore_comment(
  PhoenixKitComments.Comment.t(),
  keyword()
) :: {:ok, PhoenixKitComments.Comment.t()} | {:error, term()}
```

Restores a soft-deleted comment.

Publishes only when the site does not moderate; otherwise it goes back to
`pending`, because "undo a delete" should not also mean "approve".

# `rich_text_enabled?`

```elixir
@spec rich_text_enabled?() :: boolean()
```

Returns `true` when the rich-text (Leaf) editor should be used in the
comment composer.

The Leaf editor requires the host application to register Leaf's JS hook in
its `LiveSocket`. When the hook is missing the editor hangs on its loading
text with no server error — so hosts that haven't wired the JS (or simply
don't want rich text) can fall back to the always-working plain `<textarea>`
by setting `comments_rich_text` to `false`, or by passing
`rich_text={false}` to `CommentsComponent`.

Defaults to `true`. Leaf is provided transitively by PhoenixKit; the module
still falls back to a plain `<textarea>` whenever Leaf is unavailable.

# `search_giphy`

```elixir
@spec search_giphy(
  String.t(),
  keyword()
) :: {:ok, [gif_map()]} | {:error, atom()}
```

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"`.

# `subscribe`

```elixir
@spec subscribe(String.t(), term()) :: :ok | {:error, term()}
```

Subscribes the calling process to a resource's comment activity.

Call this from a LiveView's `mount/3` (in the connected branch) so the
view receives cross-session updates when *any* user comments on, deletes
from, or reacts to the resource:

    def mount(_params, _session, socket) do
      if connected?(socket), do: PhoenixKitComments.subscribe("order", order_uuid)
      {:ok, socket}
    end

    def handle_info({:comments_updated, %{action: action}}, socket) do
      # action is :created | :deleted | :reaction
      {:noreply, refresh_comment_badges(socket)}
    end

The broadcast payload mirrors the `{:comments_updated, …}` message the
`CommentsComponent` already sends to its own host on create/delete, so a
host has one message contract for both local and remote updates.

The PubSub server is resolved via `PhoenixKit.PubSubHelper` (configurable
with `config :phoenix_kit, pubsub: MyApp.PubSub`).

# `topic`

```elixir
@spec topic(String.t(), term()) :: String.t()
```

Returns the PubSub topic for a resource's comment activity.

Hosts rarely need this directly — use `subscribe/2` — but it's exposed so
callers can match or build topics themselves.

# `undislike_comment`

User removes dislike from a comment. Deletes the dislike row and
decrements the counter atomically. Returns `{:ok, :undisliked}` or
`{:error, :not_found}`.

# `unlike_comment`

User unlikes a comment. Deletes the like row and decrements the counter
atomically. Returns `{:ok, :unliked}` or `{:error, :not_found}`.

# `unsubscribe`

```elixir
@spec unsubscribe(String.t(), term()) :: :ok
```

Unsubscribes the calling process from a resource's comment activity.

# `update_comment`

Updates a comment.

## Parameters

- `comment` - Comment to update
- `attrs` - Attributes to update (content, status)

# `update_metadata`

```elixir
@spec update_metadata(Ecto.UUID.t() | PhoenixKitComments.Comment.t(), map()) ::
  {:ok, PhoenixKitComments.Comment.t()} | {:error, :not_found}
```

Merges `patch` into a comment's `metadata`, leaving every other key alone.

The write is a single atomic `metadata || patch` statement, so it cannot
lose a concurrent writer's keys the way read-modify-write does — and hosts
do not have to hand-write jsonb to retarget a comment.

    iex> update_metadata(comment_uuid, %{"slug" => "new-slug"})
    {:ok, %Comment{}}

Returns `{:error, :not_found}` if the row is gone.

# `update_resource_path_templates`

Updates resource templates for resource types.

Accepts both legacy string values and new map values with `"path"` and `"title"` keys.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
