Packages

Notifications for Phoenix: one event to an in-app feed, email and channels of your own, with read and unread, grouping, digests, preferences, a log of every decision, a JSON API for any frontend and a LiveView bell.

Current section

Files

Jump to
chime guides notifiers.md
Raw

guides/notifiers.md

# Notifiers

A notifier is a kind of notification of your app, in a module of its
own: who it goes to, by which channels, after how long, under which
conditions, and what it says in each. Chime has none of its own: every
app has its own kinds, so you write one for each.

```elixir
defmodule MyApp.Notifications.NewComment do
  use Chime.Notifier, category: :comments, group_window: {10, :minute}

  deliver :feed
  deliver :email, wait: {10, :minute}, unless: :read
  deliver :telegram, if: :telegram?

  @impl true
  def recipients(%{comment: c}), do: MyApp.Stories.followers(c.story_id) -- [c.author]

  @impl true
  def data(%{comment: c}), do: %{"story_id" => c.story_id, "story" => c.story.title, "text" => c.text}

  @impl true
  def actor(%{comment: c}), do: %{"id" => "user:#{c.author.id}", "name" => c.author.name}

  @impl true
  def group(%{comment: c}), do: "story:#{c.story_id}"

  @impl true
  def feed(data, notification) do
    %{
      title: "#{Chime.actors(notification)} commented on #{data["story"]}",
      body: data["text"],
      url: "/stories/#{data["story_id"]}",
      icon: "comment"
    }
  end

  @impl true
  def email(data, _recipient, notification) do
    Swoosh.Email.new(
      subject: "#{Chime.actors(notification)} commented on #{data["story"]}",
      text_body: data["text"]
    )
  end

  def telegram(data, _recipient, notification),
    do: "#{Chime.actors(notification)} commented on #{data["story"]}"

  def telegram?(_data, recipient), do: recipient.data.telegram_chat != nil
end
```

```elixir
Chime.notify(MyApp.Notifications.NewComment, %{comment: comment}, key: "comment:#{comment.id}")
```

## `use Chime.Notifier`

* `:category` - the category of the config it is of (required): what the
  preferences turn on and off.
* `:kind` - its name in the JSON API and the components
  (`"new_comment"` for `NewComment`).
* `:required` - it goes whatever the preferences, with no link to
  unsubscribe. A category of `required: true` makes all its notifiers so.
* `:group_window` - with `group/1`, how long a notification takes the
  events of its group, from the first (`{1, :hour}`).

## Who

`recipients/1` gets the params of `Chime.notify/3` and returns the
people: your users, which the resolver's `recipient/1` turns into
`Chime.Recipient`s, recipients, or ids. Each person gets the
notification once, however many times they are in the list.

`Chime.notify/3` with `:to` gives the people instead:

```elixir
Chime.notify(MyApp.Notifications.PaymentFailed, %{invoice: invoice}, to: invoice.user)
```

Nobody is told of what they did: the actor (`actor/1`) is left out of the
recipients, unless the notifier says otherwise (`notify_actor: true`).

## Actions

`feed/2` can offer buttons, and `act/3` takes them:

```elixir
@impl true
def feed(data, %{action: nil}) do
  %{
    title: "#{data["inviter"]} invited you to #{data["team"]}",
    actions: [%{key: "accept", label: "Accept"}, %{key: "decline", label: "Decline"}]
  }
end

def feed(data, %{action: action}), do: %{title: "You #{action}ed the invitation to #{data["team"]}"}

@impl true
def act("accept", data, notification), do: MyApp.Teams.join(data["team_id"], notification.recipient) |> then(fn _ -> {:ok, "/teams/#{data["team_id"]}"} end)
def act("decline", _data, _notification), do: :ok
```

`Chime.act/3` (and the feed's buttons in LiveView, and `POST
/:id/actions/:action` of the JSON API) calls `act/3`. When it says `:ok`
or `{:ok, url}`, the notification keeps the action (`notification.action`)
and is read; the person goes to `url`. An error leaves it as it was.

## Previews

`preview/0` gives the params to see the notifier with, in the admin's
previews (see [The admin](admin.md)): one example, or `{name, params}`
for more. They go through your `data/1` and `actor/1`, so plain maps of
what those read are enough:

```elixir
@impl true
def preview do
  [
    {"A comment", %{comment: %{story_id: 1, story: %{title: "Tokyo"}, text: "Lovely.", author: %{id: 7, name: "Ana"}}}},
    {"A long one", %{comment: %{story_id: 1, story: %{title: "Tokyo"}, text: String.duplicate("Lovely. ", 40), author: %{id: 7, name: "Ana"}}}}
  ]
end
```

## Guards

Your app's rules about who may be told: "may this member read the
channel?", "is this person blocked?". `notify?/2` is checked for each
person when the notification comes, with the params and the person as
the resolver gives them. A person it says no to gets nothing, by any
channel:

```elixir
@impl true
def notify?(%{message: message}, recipient), do: MyApp.Channels.member?(message.channel_id, recipient.id)
```

Each channel has its own guard too, `:if` and `:unless` (see Channels,
below): the feed's are checked when the notification comes, the others'
at their time. A notification kept out of the feed by its guard, or by
the person's preferences, is in the log with the reason.

## A base for your notifiers

Notifiers that share channels, guards or defaults can share a module of
yours, as Rails apps have an `ApplicationNotifier`:

```elixir
defmodule MyApp.Notifier do
  defmacro __using__(opts) do
    quote do
      use Chime.Notifier, unquote(opts)

      deliver :feed, if: :can_read?

      def can_read?(data, recipient), do: MyApp.Access.can_read?(recipient.id, data["channel_id"])

      @impl Chime.Notifier
      def notify?(_params, recipient), do: not MyApp.Accounts.suspended?(recipient.id)

      defoverridable notify?: 2
    end
  end
end

defmodule MyApp.Notifications.ChannelMessage do
  use MyApp.Notifier, category: :messages

  deliver :email, wait: {1, :minute}
  ...
end
```

`defoverridable` lets a notifier write its own guard over the base's.

## What is kept

`data/1` is what is kept of the params, as JSON: strings, numbers,
booleans, lists and maps. It is kept once on the event, whoever it goes
to, and the feed, the emails and your channels are made from it **when
they are shown or sent**, not when the notification comes. So the feed is
in the language of the person who reads it, as your words are then, and
a group's email says how many events it holds by the time it goes.

Keep what the words need, not your structs: the event outlives them. By
default, `data/1` is the params as JSON, which works for a map of plain
values.

## Groups

`group/1` returns a key, such as `"story:42"`, or nil. A person's
notification of the same key, unread and within the `:group_window`,
takes the new event instead of a new notification: its `count` goes up,
`actors` takes the new actor (`actor/1`, the latest first, each once),
it is unseen again, and it comes to the top of the feed. A read, an
archive, or the end of the window closes it: the next event is a new
notification.

`Chime.actors/1` says the actors ("Ana", "Ana and Rui", "Ana and 4
others") in your app's languages (see [Languages](languages.md)); an
app can write its own from `notification.actors` and
`notification.actor_count`.

While a group's email waits (`wait:`), the events that join the group go
with it: one email, as the group is when it goes, not one for each.

## Channels

`deliver/2` declares a channel, in the order they go:

* `deliver :feed` - the feed, at once. It takes `:if` and `:unless` of
  the notifier's functions, checked then.
* `deliver :email` - by the app's mailer, with `email/3`.
* `deliver :telegram` - a channel of the app's config (`Chime.Channel`),
  which calls the notifier's function of its name, `telegram/3`, when it
  has one, else uses what `feed/2` says.

Options:

* `:wait` - `{10, :minute}` (`:second`, `:minute`, `:hour`, `:day`) or
  seconds. The delivery waits, as an Oban job at its time.
* `:unless` - `:read` (the person read it in the feed meanwhile),
  `:seen` (they saw it), or the name of a function of the notifier,
  `(data, recipient) -> boolean`. A list is any of them.
* `:if` - the name of a function of the notifier, `(data, recipient) ->
  boolean`, or `{module, function}`.

Everything but the feed is decided when the delivery's time comes: the
conditions, the person's preferences then, the person themselves
(`recipients/1` of the resolver: a person who left gets nothing). Each
decision is written in the log, with its reason (see [The log](explain.md)).

## Later, and cancelled

`:at` sends a notification at a time instead of now:

```elixir
Chime.notify(MyApp.Notifications.TrialEnding, %{user: user}, to: user, at: trial_ends_at |> DateTime.add(-3, :day), key: "trial:#{user.id}")
```

The event is `"scheduled"` and a job notifies its people at its time.
Who they are, and the guard (`notify?/2`), are decided now; the feed,
the preferences and the channels then.

`Chime.cancel/2` takes the idempotency key (or the event):

```elixir
# The comment was deleted: don't email about it.
Chime.cancel("comment:#{comment.id}")

# ...and take it out of the feeds.
Chime.cancel("comment:#{comment.id}", feed: :remove)
```

A scheduled event is then not sent. A sent one has its deliveries that
wait skipped, with "the notification was cancelled" in the log; with
`feed: :remove`, its notifications leave the feeds, but those of a group
that holds other events too.

## Many people

`Chime.notify/3` inserts the notifications in batches of 1000, each its
own transaction; the first is the event's, so that a call with an
idempotency key that was made already does nothing.

For everyone, or any audience too large for a list, a notifier gives a
query of your app's instead of `recipients/1`:

```elixir
defmodule MyApp.Notifications.Announcement do
  use Chime.Notifier, category: :news

  import Ecto.Query

  deliver :feed
  deliver :email, wait: {1, :hour}

  @impl true
  def audience(%{"plan" => plan}), do: from(u in MyApp.User, where: u.plan == ^plan and not is_nil(u.confirmed_at))
  def audience(_data), do: from(u in MyApp.User, where: not is_nil(u.confirmed_at))

  @impl true
  def recipient_id(id), do: "user:#{id}"
  ...
end

Chime.notify(MyApp.Notifications.Announcement, %{"title" => "We moved", "plan" => "pro"})
```

The call returns at once, with the event `"sending"`. Oban jobs go
through the query 1000 people a page, by the key of its first binding
(`:id`, or `audience_key:` of `use`), each page with the next page's job
in its transaction, so that a page is done once whatever happens. The
query is made from the event's data (`audience/1` gets the data, not the
params), and `recipient_id/1` makes each key a person's id. When the
last page is done, the event is `"sent"` with its count of people.

The guard (`notify?/2`) is not asked of an audience: its people are the
query's. The feed's conditions and each channel's are. `Chime.cancel/2`
stops the pages that are left; with `feed: :remove`, the notifications
of the pages done go too.

## In a transaction of your app

`Chime.notify/3` inside your own `Repo.transaction/1` is part of it: the
notifications, their deliveries and their jobs are kept with your write,
or not at all. Only the live news to the open pages goes at once, before
your transaction ends.