# `MishkaCmsCore.Notify.Delivery`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/notify/delivery.ex#L5)

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 the `user_id == actor(:id)` policy — so a master receives
    notifications **from every site**, each row carrying its originating `site_id` for 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).

# `deliver`

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}`.

# `deliver`

# `deserialize`

Inverse of `serialize/1` — used by the fan-out worker.

# `fan_out`

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`

Serialize an audience into JSON-safe Oban job args. A `%User{}` collapses to its id.

# `stream`

A back-pressured stream of recipient users for `audience` — never materializes a whole site.

---

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