Per-user configuration for notification delivery channels, stored on
users.custom_fields (JSONB — no migration).
One top-level key per channel — "notification_channel:<key>" — so that
Auth.merge_user_custom_fields/3's atomic top-level custom_fields || ...
merge updates a channel without clobbering another channel or the unrelated
notification_preferences map. (A single shared "notification_channels"
object would be replaced wholesale by that shallow merge, losing concurrent
writes.)
The stored map is opaque to the core except two reserved keys the routing layer reads:
"enabled"— channel master switch (default on once configured)."types"—%{type_key => boolean}, per-type opt-in. Absent/unknown ⇒ off (external routing is fail-closed, unlike the fail-open in-app inbox).
Channel-specific keys (e.g. Telegram's "connection_uuid" / "chat_id" /
"status") live in the same map and are the channel's business.
All reads/writes take a loaded %User{} (the caller already has it — the
settings page has the current user, the delivery worker loads the recipient).
Summary
Functions
Every channel config on the user, as %{channel_key => config_map}.
Delivery cadence for type_key on this channel (from the aggregation popup) —
"immediate" (default, send each as it happens) or a digest window
("hourly" / "12h" / "daily" / "weekly", batched into one summary).
The valid delivery cadences, in escalating order.
The custom_fields key a channel's config is stored under.
Removes a channel's config entirely (e.g. user disconnects it).
Channel master switch — defaults ON once the channel has any config.
The config map for one channel (%{} if unset).
True unless type_key is routed on a digest (non-immediate) cadence.
Writes a channel's config, replacing that channel's key atomically and leaving
every other custom_fields key untouched. ensure_definitions: false because
this is an internal routing blob, not a user-facing custom-field definition.
Whether type_key is routed to this channel — fail-CLOSED (default off).
Read-modify-write a channel's config with fun. Reads the config off the
passed user; callers that must not lose a concurrent same-channel write
should pass a freshly-loaded user.
Functions
Every channel config on the user, as %{channel_key => config_map}.
Delivery cadence for type_key on this channel (from the aggregation popup) —
"immediate" (default, send each as it happens) or a digest window
("hourly" / "12h" / "daily" / "weekly", batched into one summary).
@spec cadences() :: [String.t()]
The valid delivery cadences, in escalating order.
The custom_fields key a channel's config is stored under.
Removes a channel's config entirely (e.g. user disconnects it).
Channel master switch — defaults ON once the channel has any config.
The config map for one channel (%{} if unset).
True unless type_key is routed on a digest (non-immediate) cadence.
Writes a channel's config, replacing that channel's key atomically and leaving
every other custom_fields key untouched. ensure_definitions: false because
this is an internal routing blob, not a user-facing custom-field definition.
Whether type_key is routed to this channel — fail-CLOSED (default off).
Read-modify-write a channel's config with fun. Reads the config off the
passed user; callers that must not lose a concurrent same-channel write
should pass a freshly-loaded user.