MishkaCmsCore.Notify.Delivery (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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).

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(attrs, opts)

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(audience, attrs, opts)

deserialize(map)

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

fan_out(audience, attrs, site_id, opts \\ [])

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(user)

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

stream(user, opts)

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