# `PhoenixKitComments`
[🔗](https://github.com/BeamLabEU/phoenix_kit_comments/blob/v0.4.5/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/3` - 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`

```elixir
@spec approve_comment(
  PhoenixKitComments.Comment.t(),
  keyword()
) ::
  {:ok, PhoenixKitComments.Comment.t()}
  | {:error, :comment_deleted | Ecto.Changeset.t()}
```

Sets a comment's status to published.

Refuses a soft-deleted comment with `{:error, :comment_deleted}`: approving
must never mean undeleting. The row menu hides Approve on a deleted row for
that reason, and `bulk_approve/2` counted such rows as failures — but the
single-row handler took whatever uuid the event carried, so a replayed or
hand-made `approve` published a deleted comment anyway. The rule belongs
here, at the one place every caller goes through. Use `restore_comment/2`
to bring a deleted comment back.

# `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_approve`

Approves every listed comment, as `approve_comment/2` does one.

Not `bulk_update_status(uuids, "published", opts)`: that writes the status
directly, so it logs `comment_updated` rather than `comment_approved`, and
it publishes DELETED comments — the single-row menu hides Approve on a
deleted row precisely because "approve" must never mean "undelete".
Deleted rows come back from `approve_comment/2` as `{:error,
:comment_deleted}` and are counted as failures rather than silently
skipped, so the flash tells the truth about a mixed selection.

Returns `{approved_count, failed_count}`.

# `bulk_hide`

Hides every listed comment, as `hide_comment/2` does one.

Same reason as `bulk_approve/2`: the status-writing path logs
`comment_updated`, so a bulk hide left no `comment_hidden` row behind it.

Returns `{hidden_count, failed_count}`.

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

> #### `:status` is server-side only {: .warning}
>
> An explicit `:status` in `attrs` overrides the moderation default, so a
> host that forwards raw user params into this function lets a commenter
> send `status: "published"` and skip the queue entirely. Treat it like
> `:inserted_at` and `:allow_empty_content`: set it from your own code, and
> never from anything a client can influence. `:depth` needs no such care —
> it is always recomputed from the parent here.

## 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 `:inserted_at` (a `DateTime`) to backdate the row — for
  server-created anchor/topic comments that should carry the timestamp of
  the thing they anchor (e.g. an annotation's creation time) rather than
  the moment the thread was lazily instantiated. Server-side callers only;
  never pass user input here.
  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.
  May include `:allow_empty_content` (`true`) to skip the
  content-or-media requirement — for a server-created anchor/topic
  comment whose visible text lives elsewhere (e.g. an annotation's own
  label rendered as the thread's decoration), so the thread doesn't
  have to duplicate that label into the comment body just to pass
  validation. Read directly off `attrs` before the changeset, like
  `:inserted_at` — server-side callers only; never pass user input
  here.

# `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, edits,
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 | :updated | :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/edit/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`

```elixir
@spec update_comment(PhoenixKitComments.Comment.t(), map(), keyword()) ::
  {:ok, PhoenixKitComments.Comment.t()} | {:error, Ecto.Changeset.t()}
```

Updates a comment.

## Parameters

- `comment` - Comment to update
- `attrs` - Attributes to update (content, status)
- `opts` - `:broadcast` (default `true`) — send `{:comments_updated,
  %{action: :updated}}` on the resource's topic, the same message
  create/delete send, so a host rendering a preview or count of this
  resource's comments refreshes. A caller that broadcasts its own,
  more specific action (`delete_comment/2`) passes `broadcast: false`.
  `:log` and the actor keys are forwarded to the activity log.

# `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*
