Pro Notifications
Viewing latest docs.
Switch version: v3

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:

heex
<.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:

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

elixir
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:

heex
<.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:

elixir
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:

elixir
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:

  1. The NotificationBellHook consumes 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:

elixir
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:

elixir
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

elixir
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:

elixir
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:

  1. If the type is unknown, log a warning and default to true.
  2. If the channel is not in the type’s :channels list, return false.
  3. If allow_disable_in_app: false or allow_disable_email: false for that channel, return true (forced on, user cannot override).
  4. 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:

elixir
@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:

elixir
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:

elixir
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:

elixir
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:

elixir
@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:

elixir
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:

elixir
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):

elixir
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:

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