# `PhoenixKit.Notifications`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.14.2/lib/phoenix_kit/notifications/notifications.ex#L1)

Per-user notifications driven by `PhoenixKit.Activity`.

When an activity is logged with a `target_uuid` that differs from the
`actor_uuid`, a row is inserted into `phoenix_kit_notifications` for the
target user. The user sees it in the bell dropdown (`count_unread/1`,
`recent_for_user/2`) and in the inbox at `/notifications` (`list_for_user/2`).
Each row carries its own `seen_at` and `dismissed_at` — the same activity
can be "seen but not dismissed" for one user and "unseen" for another.

The whole feature is gated by the global `notifications_enabled` setting
(default `"true"`); when `"false"`, `maybe_create_from_activity/1` is a no-op.

Registered as a core toggleable module (`use PhoenixKit.Module`) so it
appears on the admin Modules page and contributes the `/admin/notifications`
overview tab. The module enable/disable flips the same
`notifications_enabled` kill-switch `enabled?/0` reads.

# `admin_list`

Returns `{notifications, total_count}` across ALL users, newest first, for the
admin overview. Recipient and activity(+actor) are preloaded so the admin
table can show who each notification is for and what it's about.

Deliberately plain newest-first, unlike the per-user reads: "seen" belongs to
the recipient, and an admin scanning everybody's notifications is reading a
chronological record, not working through their own inbox. Sorting a shared
audit feed by whether somebody else has read each row would reorder it
differently for no one's benefit.

Options: `:page` (default 1) / `:per_page` (default 25).

# `admin_stats`

Aggregate counts for the admin overview page: total notifications,
`unread` (neither seen nor dismissed), and `dismissed`. A single
`count(...) FILTER (WHERE ...)` query — one table scan, not three.
Rescues to zeros so the page never crashes on a query hiccup.

# `count_unread`

Counts undismissed, unseen notifications for a user. Drives the badge.

# `create`

Create a **standalone** notification — one not tied to an activity
(V126). Use for app-driven notices that don't originate from the
activity log (e.g. "your export is ready").

`attrs` keys:
  * `:recipient_uuid` (required) — who receives it
  * `:text` / `:icon` / `:link` — convenience, folded into `metadata`
    as `notification_text` / `notification_icon` / `notification_link`
    (the keys `Render` reads)
  * `:metadata` — raw metadata map (merged under the convenience keys)
  * `:type` — optional notification type key (e.g. `"account"`,
    `"posts"`, or a module-contributed type). When given, the send is
    filtered through the recipient's per-type preference
    (`Prefs.user_wants_type?/2`, fail-open).
  * `:action` — optional action string (e.g. `"post.commented"`). When
    given, filtered through `Prefs.user_wants?/2` (which maps the
    action to a type). Use `:type` OR `:action`, not both.

    Notifications.create(%{
      recipient_uuid: user.uuid,
      text: "Your export is ready.",
      icon: "hero-arrow-down-tray",
      link: "/exports/123"
    })

Honors the global `notifications_enabled` kill-switch. With neither
`:type` nor `:action`, it's an unconditional app-driven send (no
preference filtering). Returns `{:ok, %Notification{}}`,
`{:ok, :skipped}` (disabled or filtered out by prefs), or
`{:error, changeset}`. Broadcasts `{:notification_created, n}` on success.

# `create_inapp`

```elixir
@spec create_inapp(String.t(), map()) ::
  {:ok, PhoenixKit.Notifications.Notification.t()} | {:error, term()}
```

Insert an in-app-only notification row directly — used by the DigestWorker to
post an aggregated in-app summary ("1,432 likes this hour"). Bypasses the
kill-switch/preference checks (the digest already decided to post) and never
routes externally. `display` carries `:text` / `:icon` / `:link`.

# `create_many`

Create a standalone notification for **many** recipients in one call —
the multi-recipient counterpart to `create/1`. `recipient_uuids` is a
list; `attrs` is the same shape as `create/1` minus `:recipient_uuid`
(it's supplied per recipient).

The recipient list is the caller's responsibility (e.g. the followers
of an author) — this is the generic fan-out primitive, not an audience
resolver. Duplicate uuids are de-duped. Each recipient is filtered
independently through `:type` / `:action` prefs when given, so muted
users are skipped. Honors the kill-switch once up front.

    Notifications.create_many(follower_uuids, %{
      type: "posts",
      text: "Alice published a new post.",
      link: "/posts/#{post.id}"
    })

Returns `{:ok, created_count}` (notifications actually inserted, i.e.
excluding disabled / pref-skipped) or `{:ok, :skipped}` when
notifications are globally disabled.

# `dismiss`

Dismisses a single notification, also marking it read. Idempotent.

# `dismiss_all`

Bulk-dismisses all undismissed notifications, also marking them read. Returns `{count, nil}`.

# `enabled?`

Is the notifications feature enabled? Default `true`.

# `fan_out_from_activity`

```elixir
@spec fan_out_from_activity(PhoenixKit.Activity.Entry.t(), [String.t()]) :: [
  ok: PhoenixKit.Notifications.Notification.t() | :skipped,
  error: term()
]
```

Fans ONE committed activity entry out to MANY users' notification
rules — the multi-recipient counterpart to `maybe_create_from_activity/1`
for events whose audience is a set (project members, watchers) rather
than a single `target_uuid`.

Each recipient is evaluated independently through the SAME machinery
(prefs, per-channel routing, digest cadences, self-action skip) by
re-routing a copy of the entry with that recipient as target — WITHOUT
inserting additional activity rows, so the feed keeps one canonical
entry while deliveries still key on its committed uuid.

Returns the per-recipient results in order.

# `get_notification`

Fetches one notification scoped to the recipient. Returns `nil` if missing.

# `list_for_user`

Returns `{notifications, total_count}` for the given user — unseen first,
then newest first within each group.

Options:
  * `:page` (default 1) / `:per_page` (default 25)
  * `:status` — `:unread` (seen_at nil) | `:all` (default)
  * `:dismissed` — `:exclude` (default, active only) | `:only` (the dismissed
    "trash" view) | `:include` (both). The legacy `:include_dismissed` bool is
    still honored (`true` ⇒ `:include`).

# `mark_all_seen`

Bulk-marks all unseen notifications as seen. Returns `{count, nil}`.

# `mark_seen`

Marks a single notification as seen. Idempotent — already-seen rows return
`{:ok, notification}` unchanged.

# `maybe_create_from_activity`

Inserts a notification for the activity's target user, if the rules allow it.

Returns one of:
  * `{:ok, %Notification{}}` — row created; broadcast on the per-user topic
  * `{:ok, :skipped}` — filtered out (no target, self-action, feature disabled)
  * `{:error, changeset}` — insert failed (logged, never raised)

# `prune`

Deletes notifications whose underlying activity is older than `days`.

# `recent_for_user`

Returns the N most-recent undismissed notifications for a user, unseen
first.

Drives the bell dropdown. Activity (and actor) are preloaded.

Unseen first matters more here than in the full list: the dropdown shows a
handful, so with a plain newest-first order a notification you had already
read could push an unread one off the bottom entirely — the badge counts it,
and opening the bell doesn't show it.

# `restore`

Restores (un-dismisses) a single notification. Idempotent.

# `retention_days`

Retention period in days. Falls back to activity retention if unset.

# `upsert_inapp`

```elixir
@spec upsert_inapp(String.t(), String.t(), map()) ::
  {:ok, PhoenixKit.Notifications.Notification.t()}
  | {:ok, :skipped}
  | {:error, term()}
```

Post an in-app notification, or refresh the one already standing for the
same `key`.

This is the GitHub-style collapsing entry — "3 new comments on earlier
chapters" — where a second event should update the row a user has not dealt
with yet rather than add another beside it.

Doing that without an API meant reaching past it: querying
`PhoenixKit.Notifications.Notification` directly, `update_all`-ing the row,
and then re-broadcasting `:notification_created` by hand, because the only
broadcast fired on insert. One host that tried it with a schemaless write
stringified `metadata` into the jsonb column and 500'd that user's bell.

## What counts as "already standing"

An undismissed, **unseen** notification for the same recipient carrying the
same key. Once someone has read it, the next event is news again and gets
its own row — collapsing into something already read would hide it, and the
unseen-first ordering exists precisely so unread work stays visible.

## Refreshing

`display` replaces the text/icon/link, and `inserted_at` moves to now so the
refreshed entry sorts as new. Any other metadata keys the caller passes are
merged, leaving keys it doesn't mention alone.

    iex> upsert_inapp(user_uuid, "comments:chapter:42", %{text: "3 new comments"})
    {:ok, %Notification{}}

Broadcasts either way — `{:notification_created, n}` for a new row,
`{:notification_updated, n}` for a refresh — so a bell that is already open
reflects it without the caller broadcasting anything itself.

Honors the `notifications_enabled` kill switch like `create/1` does,
returning `{:ok, :skipped}` when notifications are off — this is a
host-facing entry point, not the digest's private lane, and "off" that
quietly doesn't apply to the newest creation path isn't off.

---

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