Turns one deliver/3 call into many per-recipient rows, fast and at scale.
Tenancy — who sees what
A notification's site_id is the site the event concerns (the originating site), passed as
opts[:site_id] (or derived from a :permission/:site_admins audience). It is nil only for
platform-level events. Consequences:
- A tenant user (bound to one site) reads scoped to their site and therefore sees only that site's notifications.
- A master user (
site_id == nil, cross-site) reads unscoped (global? true+ no tenant → all rows), narrowed to their own by theuser_id == actor(:id)policy — so a master receives notifications from every site, each row carrying its originatingsite_idfor filtering.
So every recipient row of one deliver call gets the same originating site_id (via
tenant: site_id → the :deliver change), regardless of whether the recipient is a tenant user
or a master.
Never blocks the caller's request
Delivery ALWAYS hands off — it never fans out on the request path. In prod that's a single fast
Oban.insert (queue :notify); the actual per-recipient writes happen in
MishkaCmsCore.Notify.Workers.FanoutWorker, which STREAMS the audience in batches so a 100k-user
site is written in constant memory, off-request. So a producer reacting to (say) a comment can
call deliver/3 and return immediately. Under test the same fan_out/4 runs inline (Oban isn't
draining) so assertions still see the rows.
Audiences (who)
An audience is resolved here, built to scale — stream/2 pages recipients in batches via
Ash.stream! (constant memory even for a 100k-user site) and filters permission audiences in
Elixir (wildcards can't be matched in SQL). Shapes: %User{} / {:user, id} / {:users, ids} /
{:permission, perm|[perm], site_id} / {:permission_masters, perm|[perm]} / :masters /
{:site_admins, site_id} (or a list to combine). A perm is a name or {:under, prefix} (see
MishkaCmsCore.Accounts.Checks.AccessPermissions). serialize/1+deserialize/1 round-trip an
audience through the Oban job args, an {:under, prefix} as %{"under" => prefix}, and
{:under, :surface} as the dashboard's, where notifications are read.
See MishkaCmsCore.Notify (the public facade).
Summary
Functions
Deliver a notification. Two forms
Inverse of serialize/1 — used by the fan-out worker.
The scalable fan-out: stream recipients for spec, bulk-create with tenant: site_id in
@bulk_batch chunks. Called inline under test and by FanoutWorker.perform/1 in prod. attrs
may have string or atom keys (round-tripped through Oban args). Returns recipients written.
Serialize an audience into JSON-safe Oban job args. A %User{} collapses to its id.
A back-pressured stream of recipient users for audience — never materializes a whole site.
Functions
Deliver a notification. Two forms:
# Plugin-friendly — attrs first, audience as named opts (recommended for producers):
deliver(%{type: ..., title: ...},
tenant_permissions: "admin:dashboard:blog:comment", # tenant admins on `site_id` holding this
master_permissions: "admin:dashboard:blog:comment", # every master admin holding this
site_id: site_id, actor: actor, except: [actor_id])
# Core — an explicit audience (or list of audiences) first:
deliver({:user, id}, %{type: ..., title: ...})Audience opts (any combination; recipients de-duplicated by id): :user (id / %User{}),
:users (ids), :tenant_permissions (perm | [perm] → tenant admins on :site_id),
:master_permissions (perm | [perm] → master admins), :masters (true → every master),
:site_admins (a site id → whoever holds anything under its admin:dashboard). attrs needs at
least :type and :title.
Other opts: :site_id (the originating site; nil = platform-level), :actor (%User{} / id →
actor_id), :except (user id / [ids] to drop — pass the actor so they aren't notified of their
own action), :dedup_key (Oban unique).
Returns {:ok, %{delivered: n}} (inline) / {:ok, :queued} (deferred) / {:error, reason}.
Inverse of serialize/1 — used by the fan-out worker.
The scalable fan-out: stream recipients for spec, bulk-create with tenant: site_id in
@bulk_batch chunks. Called inline under test and by FanoutWorker.perform/1 in prod. attrs
may have string or atom keys (round-tripped through Oban args). Returns recipients written.
Serialize an audience into JSON-safe Oban job args. A %User{} collapses to its id.
A back-pressured stream of recipient users for audience — never materializes a whole site.