User Notifications
A realtime notification/broadcast system for authenticated users. Petal Pro includes a Notification Bell that displays unread notifications in the header.
The Notification Bell Component is embedded in the Side Bar layout and Stacked Layout via core_components.ex. If you want to omit the Notification Bell Component, you can use the :show_notification_bell attribute:
<.layout show_notification_bell={false} current_page={:dashboard} current_user={@current_user} type="sidebar">
<.container max_width="xl">
<div>content</div>
</.container>
</.layout>
You can add the Notification Bell Component anywhere you like with the following code:
<.live_component
module={PetalProWeb.NotificationBellComponent}
id={PetalProWeb.NotificationBellComponent.lc_id()}
current_user={@current_user}
/>
Sending Notifications
In v4, Notifications.notify/3 is the single entry point for sending any notification. It checks the recipient’s per-type preferences before dispatching — you no longer need to call create_user_notification/1 and broadcast_user_notification/1 separately.
alias PetalPro.Notifications
alias PetalPro.Notifications.UserNotificationAttrs
# In-app only (no email_fun)
attrs = UserNotificationAttrs.invite_to_org_notification(org, sender_id, recipient_id)
Notifications.notify(attrs, recipient_user)
# In-app + email
Notifications.notify(attrs, recipient_user,
email_fun: fn -> UserMailer.deliver_org_invitation(org, invitation, url) end
)
notify/3 returns {:ok, %{in_app: result, email: result}} where each result is nil (channel skipped by preference), {:ok, record}, or {:error, reason}.
The lower-level create_user_notification/1 + broadcast_user_notification/1 pair still exists for fine-grained control, but for new notifications always prefer notify/3.
Viewing Notifications
Notifications for the currently authenticated user are visible via the Notification Bell Component. Unread notifications are indicated with a badge on the bell icon.
The Notification Bell Component comes with its own event handlers. This is why it’s implemented as a Live Component:
<.live_component
module={PetalProWeb.NotificationBellComponent}
id={PetalProWeb.NotificationBellComponent.lc_id()}
current_user={@current_user}
/>
Clicking on a notification will navigate the user to a nominated route. The :require_authenticated_user hook has been updated to mark notifications read if the read path matches.
Consuming Broadcast Messages
The UserNotificationChannel is a Channel that is configured in user_socket.ex:
defmodule PetalProWeb.UserSocket do
use Phoenix.Socket
channel "user_notifications:*", PetalProWeb.UserNotificationsChannel
...
end
If you send a broadcast using broadcast_user_notification/1, it will create a Topic with the users’ id:
defmodule PetalPro.Notifications do
def user_notifications_topic(user_id) when not is_nil(user_id),
do: "user_notifications:#{user_id}"
def broadcast_user_notification(%UserNotification{
id: notification_id,
type: notification_type,
recipient_id: recipient_id
}) do
PetalProWeb.Endpoint.broadcast(
user_notifications_topic(recipient_id),
"notifications_updated",
%{id: notification_id, type: notification_type}
)
end
end
You can then consume this broadcast using any device (e.g. browser or mobile) using a client library that supports Phoenix Channels.
ℹ️ Information: Broadcast messages can only be created and consumed by authenticated users. See user_socket.ex if you want to see how this works.
Consuming via the Notification Bell
In the case of Petal Pro, the Notification Bell Component includes code that intercepts broadcast messages. This is achieved via a JavaScript Hook:
-
The
NotificationBellHookconsumes broadcast messages client-side <div class=”bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-400 rounded-r-lg p-4 my-4 text-blue-900 dark:text-blue-100”><p>ℹ️ <strong>Information:</strong> Though data is sent via the Channel, ultimately the data that is displayed by the Notification Bell Component is loaded from the database (not the Channel).</p></div>
Consuming via a Live View
To consume the broadcast directly in a Live View, add a call to PetalProWeb.Endpoint.subscribe and a handle_info callback. The following example adjusts DashboadLive:
defmodule PetalProWeb.DashboardLive do
@moduledoc false
use PetalProWeb, :live_view
alias PetalPro.Notifications
@impl true
def mount(_params, _session, socket) do
current_user = socket.assigns.current_user
if current_user do
topic = Notifications.user_notifications_topic(current_user.id)
PetalProWeb.Endpoint.subscribe(topic)
end
{:ok, assign(socket, page_title: gettext("Dashboard"))}
end
@impl true
def handle_info(%{event: "notifications_updated"}, socket) do
# Do something here...
{:noreply, socket}
end
end
The Read Path
If you look at UserNotificationAttrs.invite_to_org_notification/3:
def invite_to_org_notification(%Org{} = org, sender_id, recipient_id) do
%{
read_path: ~p"/app/users/org-invitations",
type: :invited_to_org,
recipient_id: recipient_id,
sender_id: sender_id,
org_id: org.id,
message: gettext("You have been invited to join the %{org_name} organisation!", org_name: org.name)
}
end
You’ll see that it has a read_path property. This must be a verified route - if the user navigates to this page, then the notification is marked as read (handled in the :require_authenticated_user``on_mount hook).
Per-type Notification Preferences
Users can control which notification types reach them and via which channel. Each type independently supports toggling in-app and email delivery. Some types are “forced on” — security-critical notifications like password change alerts — and cannot be disabled regardless of user preference.
The Preference Schema
typed_schema "user_notification_preferences" do
field :notification_type, :string
field :in_app_enabled, :boolean, default: true
field :email_enabled, :boolean, default: true
belongs_to :user, User
timestamps(type: :utc_datetime)
end
Preference rows are created lazily — a missing row means “use defaults” (both channels enabled). This keeps the table small for users who never touch their settings.
How Preferences Are Checked
Preferences.enabled?/3 is the gate. notify/3 calls it before dispatching each channel:
Preferences.enabled?(user_id, :invited_to_org, :in_app) # => true/false
Preferences.enabled?(user_id, :invited_to_org, :email) # => true/false
The logic, in order:
-
If the type is unknown, log a warning and default to
true. -
If the channel is not in the type’s
:channelslist, returnfalse. -
If
allow_disable_in_app: falseorallow_disable_email: falsefor that channel, returntrue(forced on, user cannot override). - Otherwise, check the user’s preference row. If none exists, use the type’s default.
Type Definitions
All notification types and their defaults are declared in PetalPro.Notifications.Preferences:
@type_definitions %{
invited_to_org: %{
label: "Organization invitations",
category: :organizations,
channels: [:in_app, :email],
allow_disable_in_app: true,
allow_disable_email: true
},
welcome: %{
label: "Welcome message",
category: :account,
channels: [:in_app],
allow_disable_in_app: false, # forced on
allow_disable_email: false
},
password_changed: %{
label: "Password changed",
category: :account,
channels: [:in_app, :email],
allow_disable_in_app: false, # forced on
allow_disable_email: false # forced on
},
subscription_created: %{
label: "Subscription confirmed",
category: :billing,
channels: [:in_app, :email],
allow_disable_in_app: true,
allow_disable_email: true
},
marketing_notifications: %{
label: "Marketing emails",
category: :marketing,
channels: [:email],
allow_disable_in_app: false,
allow_disable_email: true,
# In GDPR mode new users are opted out; otherwise opted in by default
default_enabled: %{email: not PetalPro.gdpr_mode()}
},
new_changelog_update: %{
label: "Changelog updates",
category: :product,
channels: [:in_app, :email],
allow_disable_in_app: true,
allow_disable_email: true
}
}
Updating Preferences
Use Preferences.upsert_preference/4 to toggle a channel for a user:
alias PetalPro.Notifications.Preferences
# Disable email for changelog updates
Preferences.upsert_preference(user.id, :new_changelog_update, :email, false)
# Re-enable in-app for org invitations
Preferences.upsert_preference(user.id, :invited_to_org, :in_app, true)
The settings UI uses Preferences.list_preferences_for_user/1 to render all types with current values merged in.
Built-in Notifications (v4)
Petal Pro v4 ships two new notification types that fire automatically.
Welcome Notification
Sent when a user registers. In-app only, forced on:
attrs = UserNotificationAttrs.welcome_notification(user.id)
Notifications.notify(attrs, user)
Password Change Alert
Sent whenever a user successfully changes their password. Both in-app and email, forced on:
attrs = UserNotificationAttrs.password_changed_notification(user.id)
Notifications.notify(attrs, user,
email_fun: fn -> UserMailer.deliver_password_changed_alert(user) end
)
The email prompts users to contact support if the change wasn’t made by them.
Creating a New Notification Type
Here’s the complete process using “promoted to org admin” as the example.
Step 1: Add the type to UserNotification
In lib/petal_pro/notifications/user_notification.ex, add your type to @notification_types:
@notification_types [
:invited_to_org,
:welcome,
:password_changed,
:subscription_created,
:new_changelog_update,
:promoted_to_org_admin # <-- add this
]
Step 2: Register it in Preferences
In lib/petal_pro/notifications/preferences.ex, add a definition to @type_definitions:
promoted_to_org_admin: %{
label: "Promoted to org admin",
description: "When you are promoted to admin in an organization",
category: :organizations,
channels: [:in_app, :email],
allow_disable_in_app: true,
allow_disable_email: true
}
Step 3: Add the attrs helper
In lib/petal_pro/notifications/user_notification_attrs.ex:
def promote_to_org_admin_notification(%Org{} = org, sender_id, recipient_id) do
%{
read_path: ~p"/app/org/#{org.slug}/team",
type: :promoted_to_org_admin,
recipient_id: recipient_id,
sender_id: sender_id,
org_id: org.id,
message: gettext("You have been promoted to admin for %{org_name}!", org_name: org.name)
}
end
Note that the read_path points to the org/team route — navigating there automatically marks the notification as read.
Step 4: Send the notification
Call notify/3 wherever the event happens (e.g. in Orgs.update_membership/2):
alias PetalPro.Notifications
alias PetalPro.Notifications.UserNotificationAttrs
attrs = UserNotificationAttrs.promote_to_org_admin_notification(org, current_user.id, member.user_id)
Notifications.notify(attrs, member.user,
email_fun: fn -> UserMailer.deliver_promoted_to_org_admin(member.user, org) end
)
Step 5: Add a notification item component
In notification_components.ex, add a function clause for your type. Without this, the Notification Bell raises a FunctionClauseError when rendering:
def notification_item(%{notification: %{type: :promoted_to_org_admin}} = assigns) do
~H"""
<.notification_item_wrapper notification={@notification}>
<:icon>
<.icon name="hero-shield-check" class="h-5 w-5 text-green-500" />
</:icon>
{@notification.message}
</.notification_item_wrapper>
"""
end
That’s the complete flow. The new type appears in the user’s notification preferences settings page, respects their channel choices, and renders correctly in the bell dropdown.