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.
pub type Email {
NoEmail
EmailNow
EmailDigest
}
Constructors
-
NoEmail -
EmailNowEmailed on its own, unless it was read in the inbox first; see
email_after. -
EmailDigestGathered 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.
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 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 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 fn new(repo: repo.Repo) -> Notifier
Notifications kept in repo, which must have had schema applied.
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 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 takeslimit,before(an ID, for the next page) andunread=truein the query.GET /eventsis thestreamof the inbox.POST /readmarks every notification read.POST /:id/readandPOST /:id/unreadmark one.DELETE /:iddeletes 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 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.