howdy/notifications

Notifications: telling someone that something happened, kept in an inbox until they have read it, and pushed to every page they have open.

import howdy/notifications

let notifier = notifications.new(repo)

notifications.draft("comment.created", "Ana commented on your post")
|> notifications.body("Looks good to me!")
|> notifications.link("/posts/42#comment-7")
|> notifications.send(notifier, to: post.author_id)

A notification is saved first, then announced to the recipient’s open pages. The saved row is what counts: a page that was closed, or lost its connection, reads the inbox when it next connects, so nothing is missed.

Pages hear about notifications through routes, which serves the inbox as JSON and as a stream of server-sent events, and howdy/ui/bell shows them. A live view, or any other process, can subscribe.

Each kind of notification can also go out by email, straight away or gathered into a digest, and by push: to the browser’s own notifications with howdy/notifications/web_push, and to any other push service the app adds with with_push_service. Users choose per kind with set_preference, and the app sets the defaults with with_defaults. Email and push are background jobs of howdy_jobs, queued in the transaction that saves the notification and done by the app’s runner (handle_jobs), which retries what fails.

Recipients are user IDs as strings, such as howdy_auth user IDs. The package does not check them against any table, so it works with any account system.

Types

How one kind of notification reaches a user.

pub type Delivery {
  Delivery(inbox: Bool, email: Email, push: Bool)
}

Constructors

  • Delivery(inbox: Bool, email: Email, push: Bool)

    Arguments

    inbox

    Shown in the inbox and on open pages.

    push

    Pushed by every push service the notifier has, such as to the browsers the user turned on with web_push.

A notification about to be sent. Start one with draft.

pub opaque type Draft
pub type Email {
  NoEmail
  EmailNow
  EmailDigest
}

Constructors

  • NoEmail
  • EmailNow

    Emailed on its own, unless it was read in the inbox first; see email_after.

  • EmailDigest

    Gathered with others into one email; see with_digest.

What a subscriber hears about a user’s inbox.

pub type Event {
  Received(Notification)
  Unread(count: Int)
}

Constructors

  • Received(Notification)

    A new notification arrived.

  • Unread(count: Int)

    The number of unread notifications changed, because one arrived or was read, marked unread or deleted, on this page or any other.

A notification as it was saved. Times are unix milliseconds.

pub type Notification {
  Notification(
    id: String,
    recipient: String,
    kind: String,
    title: String,
    body: String,
    link: option.Option(String),
    data: dict.Dict(String, String),
    created_at: Int,
    read_at: option.Option(Int),
  )
}

Constructors

  • Notification(
      id: String,
      recipient: String,
      kind: String,
      title: String,
      body: String,
      link: option.Option(String),
      data: dict.Dict(String, String),
      created_at: Int,
      read_at: option.Option(Int),
    )

    Arguments

    recipient

    The user it is for.

    kind

    What happened, in the app’s own terms, such as comment.created. Preferences are chosen per kind.

    link

    Where to go to see what happened.

    data

    Anything else the app wants to keep with it, such as IDs.

Everything sending needs. Build with new and the with_ functions, and keep one for the life of the app.

pub opaque type Notifier

What became of one attempt to deliver a notification by email or push.

pub type Outcome {
  Delivered
  Retry(reason: String)
  GiveUp(reason: String)
}

Constructors

  • Delivered
  • Retry(reason: String)

    It may work later, such as when the provider is down. It is tried again with a growing delay, five times in all.

  • GiveUp(reason: String)

    It never will, such as when the user has no email address.

Which part of the inbox to read. Build with page.

pub opaque type Page

A subscription made with subscribe.

pub opaque type Subscription

Values

pub fn across_servers(
  notifier: Notifier,
) -> supervision.ChildSpecification(Nil)

Announce through PostgreSQL, so that pages open on any server hear about their user’s notifications, whether or not the servers are connected as an Erlang cluster. Add it to the app’s supervisor on every server, after the database pool and before the server:

supervisor.new(supervisor.OneForOne)
|> supervisor.add(pool)
|> supervisor.add(notifications.across_servers(notifier))
|> supervisor.add(jobs.supervised(runner))
|> supervisor.add(howdy.supervised(app(notifier)))

It listens on a connection of its own, opened with the pool’s settings; see across_servers_via for a pool howdy/database/postgres did not open. One runs per node. On SQLite, which only one server can use, it changes nothing. While it is not running, such as when it restarts, announcements go through the cluster as they do without it. What is announced while a server’s listening connection is lost does not reach that server’s open pages, but the inbox has it: the bell reads it when its panel next opens.

pub fn across_servers_via(
  notifier: Notifier,
  config: pog.Config,
) -> supervision.ChildSpecification(Nil)

across_servers, listening with these settings, such as pog.url_config(name, url): the same the app’s pool uses. The pool name in them is not used.

pub fn announce(recipient: String, event: Event) -> Nil

Tell a user’s subscribers something, from any node. send does this itself; this is for events of the app’s own making, such as an unread count changed by a bulk update.

pub fn before(page: Page, id: String) -> Page

Those older than the notification with this ID, for the next page.

pub fn body(draft: Draft, body: String) -> Draft

More detail, shown under the title.

pub fn clear_preference(
  notifier: Notifier,
  recipient: String,
  kind: String,
) -> Result(Nil, service.Error)

Forget a user’s choice for kind, so the default applies again.

pub fn data(draft: Draft, data: List(#(String, String))) -> Draft

Values to keep with the notification, such as the IDs the app needs to act on it. They are sent to the browser with it.

pub const default_delivery: Delivery

What happens to a kind nobody chose for: shown in the inbox and pushed to browsers that asked for it, and not emailed.

pub fn delete(
  notifier: Notifier,
  recipient: String,
  id: String,
) -> Result(Nil, service.Error)

Remove a notification from a user’s inbox for good.

pub fn delete_user(
  conn: repo.Repo,
  recipient: String,
) -> Result(Nil, service.Error)

Remove everything kept for a user: notifications, preferences and push subscriptions. conn may be a transaction, such as the one auth.with_account_deletion gives, so it all goes with the account.

pub fn draft(kind: String, title: String) -> Draft

A notification of kind with title, its one line summary.

pub fn email_after(notifier: Notifier, seconds: Int) -> Notifier

Wait this long before emailing a notification, and skip the email if it is read in the inbox meanwhile. Someone who is already looking at the app does not need an email too. Defaults to zero.

pub const every_kind: String

The kind a preference applies to when it is set for every kind.

pub fn get(
  notifier: Notifier,
  recipient: String,
  id: String,
) -> Result(Notification, service.Error)

One of a user’s notifications.

pub fn handle_jobs(
  runner: jobs.Runner,
  notifier: Notifier,
) -> jobs.Runner

Teach the app’s jobs runner to send notifications’ email, digests and push. They run on the notifications queue, ten at a time unless the runner says otherwise with jobs.queue. Every node running the runner shares the work.

let runner =
  jobs.runner(db)
  |> notifications.handle_jobs(notifier)

supervisor.new(supervisor.OneForOne)
|> supervisor.add(jobs.supervised(runner))
|> supervisor.add(howdy.supervised(app(notifier)))

In tests, jobs.drain(runner) sends whatever is due.

pub fn limit(page: Page, limit: Int) -> Page

At most limit notifications, between 1 and 100.

pub fn link(draft: Draft, link: String) -> Draft

Where opening the notification goes, as a path such as /posts/42. Email and push need a full URL; see with_base_url.

pub fn list(
  notifier: Notifier,
  recipient: String,
  page: Page,
) -> Result(List(Notification), service.Error)

A user’s inbox, newest first.

pub fn mark_all_read(
  notifier: Notifier,
  recipient: String,
) -> Result(Nil, service.Error)

Mark every notification in a user’s inbox read.

pub fn mark_read(
  notifier: Notifier,
  recipient: String,
  id: String,
) -> Result(Nil, service.Error)

Mark one notification read. Reading one already read changes nothing.

pub fn mark_unread(
  notifier: Notifier,
  recipient: String,
  id: String,
) -> Result(Nil, service.Error)

Mark one notification unread again.

pub const muted: Delivery

Nothing at all: the notification is not even saved.

pub fn new(repo: repo.Repo) -> Notifier

Notifications kept in repo, which must have had schema applied.

pub fn page() -> Page

The newest twenty notifications.

pub fn preference(
  notifier: Notifier,
  recipient: String,
  kind: String,
) -> Result(Delivery, service.Error)

How kind reaches recipient: their choice for the kind, else their choice for every_kind, else the app’s default.

pub fn preferences(
  notifier: Notifier,
  recipient: String,
) -> Result(List(#(String, Delivery)), service.Error)

The choices a user has made, by kind, every_kind included.

pub fn prune(
  notifier: Notifier,
  read_before before: Int,
) -> Result(Nil, service.Error)

Delete notifications that were read before before, in unix milliseconds, so the table does not grow for ever. Unread ones are kept. Call it now and then, such as daily.

pub const queue: String

The queue the delivery jobs go to.

pub fn repo(notifier: Notifier) -> repo.Repo
pub fn routes(
  notifier: Notifier,
  at prefix: String,
  identify identify: fn(context.Context(Nil)) -> Result(
    String,
    service.Error,
  ),
) -> controller.Builder(Nil)

The inbox over HTTP, for howdy/ui/bell or a page of your own. identify names the signed-in user, or refuses the request:

notifications.routes(notifier, at: "/notifications", identify: fn(ctx) {
  auth.required(auth)(ctx) |> result.map(fn(principal) { principal.user.id })
})

Writes come from the user’s own browser with their cookie, so identify must refuse cross-site requests, as auth.required does, or the app must use howdy/csrf.

  • GET / lists the inbox as {"notifications": [...], "unread": 3}. It takes limit, before (an ID, for the next page) and unread=true in the query.
  • GET /events is the stream of the inbox.
  • POST /read marks every notification read.
  • POST /:id/read and POST /:id/unread mark one.
  • DELETE /:id deletes one.

Writes answer {"unread": 2} with the new count. A plain HTML form can post to them too: give the action a next query parameter, a path such as ?next=/inbox, and the answer is a redirect there instead.

pub fn schema() -> migration.Package

The migrations for the package’s tables, all named howdy_notifications_.

pub fn send(
  draft: Draft,
  notifier: Notifier,
  to recipient: String,
) -> Result(option.Option(Notification), service.Error)

Save a notification for recipient, announce it to their open pages, and queue whatever email and push their preferences ask for. Gives None when they have muted the kind.

Call it after the change it is about has been committed, not inside the transaction making it: the announcement cannot be taken back.

pub fn send_to_each(
  draft: Draft,
  notifier: Notifier,
  recipients: List(String),
) -> Result(List(Notification), service.Error)

Send the same notification to each recipient, one at a time, stopping at the first error.

pub fn set_preference(
  notifier: Notifier,
  recipient: String,
  kind: String,
  delivery: Delivery,
) -> Result(Nil, service.Error)

Choose how kind, or every_kind, reaches a user. Use muted to stop it altogether.

pub fn stream(
  ctx: context.Context(Nil),
  notifier: Notifier,
  recipient: String,
) -> response.Response(content.Content)

A server-sent event stream of recipient’s inbox. It opens with an unread event giving the count, then sends a notification event for each that arrives and an unread event whenever the count changes:

event: notification
data: {"id": "…", "title": "Ana commented on your post", …}

event: unread
data: {"count": 3}

recipient must be the signed-in user; take it from the session, never from the request.

pub fn subscribe(
  recipient: String,
  subject: process.Subject(Event),
) -> Subscription

Hear about recipient’s inbox on subject, from any node in the cluster, or any server at all with across_servers, until the process that owns the subject exits or unsubscribe is called. Nothing announced after this returns is missed.

In a live view, subscribe from an effect:

server_component.select(fn(_dispatch, subject) {
  notifications.subscribe(user_id, subject)
  process.new_selector() |> process.select_map(subject, Notified)
})
pub fn subscribers(recipient: String) -> Int

How many subscriptions a user has, across the cluster. Servers that are not clustered, even with across_servers, only count their own.

pub fn to_json(notification: Notification) -> json.Json
pub fn unread_count(
  notifier: Notifier,
  recipient: String,
) -> Result(Int, service.Error)

How many notifications in the inbox are unread.

pub fn unread_only(page: Page) -> Page

Only unread notifications.

pub fn unsubscribe(subscription: Subscription) -> Nil
pub fn url(
  notifier: Notifier,
  notification: Notification,
) -> String

The full URL of a notification’s link, using with_base_url; the base URL itself when it has none. A link that is already a full URL is kept.

pub fn with_base_url(
  notifier: Notifier,
  base_url: String,
) -> Notifier

The address the app is served from, such as https://example.com, so email and push can link to it. See url.

pub fn with_defaults(
  notifier: Notifier,
  defaults: fn(String) -> Delivery,
) -> Notifier

How each kind is delivered when the user has not chosen. Kinds the function does not know should get default_delivery.

notifications.with_defaults(notifier, fn(kind) {
  case kind {
    "security." <> _ -> Delivery(..default_delivery, email: EmailNow)
    "comment.created" -> Delivery(..default_delivery, email: EmailDigest)
    _ -> default_delivery
  }
})
pub fn with_digest(
  notifier: Notifier,
  every every_seconds: Int,
  render render: fn(List(Notification)) -> mail.Message,
) -> Notifier

Gather EmailDigest notifications into one email per user at most every every_seconds, such as 86_400 for a daily digest. render writes it from the user’s unread notifications, oldest first; those read in the meantime are left out. Needs with_email.

Without a digest, EmailDigest notifications are emailed one by one.

pub fn with_email(
  notifier: Notifier,
  mailer: mail.Mailer,
  address address: fn(String) -> Result(mail.Address, Nil),
  render render: fn(Notification) -> mail.Message,
) -> Notifier

Send email for the kinds whose delivery asks for it. address finds a user’s address, and render writes the email for one notification: its subject and body, but not its recipient, which is filled in.

notifications.with_email(notifier, mailer,
  address: fn(user_id) { users.email_address(user_id) },
  render: fn(n) {
    mail.message()
    |> mail.subject(n.title)
    |> mail.text(n.body <> "\n\n" <> notifications.url(notifier, n))
  },
)
pub fn with_push(
  notifier: Notifier,
  deliver: fn(Notification) -> Outcome,
) -> Notifier

Deliver push notifications with deliver, such as the one web_push.deliver makes. It is the push service named "default": calling with_push again replaces it. Add more with with_push_service.

pub fn with_push_service(
  notifier: Notifier,
  named name: String,
  deliver deliver: fn(Notification) -> Outcome,
) -> Notifier

Deliver push notifications with deliver too, as the push service called name, such as one sending to phones. A notification the user wants pushed goes to every service, each in a job of its own: one that fails or is retried does not hold up or repeat the others. Adding a name that is already there replaces its service.

notifier
|> notifications.with_push_service("web", web_push.deliver(push))
|> notifications.with_push_service("phones", my_app.send_to_phones)

deliver is given the notification and finds the user’s devices itself. Keep a name once notifications have been sent with it: jobs waiting for a service that is gone fail.

✨ Search Document