Pro Customization Recipes
Viewing latest docs.
Switch version: v3

Customization Recipes

A quick-reference cheat sheet for common Petal Pro customizations. Each recipe tells you what to touch and gives you the minimal code to get it done.


1. Adding a Field to the User Schema

Files to touch:

  • Migration (generated)
  • lib/petal_pro/accounts/user.ex — schema + changeset
  • The relevant form template (e.g., edit_profile_live.ex or admin_user_live)

Steps:

Generate a migration:

bash
mix ecto.gen.migration add_company_to_users
elixir
# priv/repo/migrations/xxx_add_company_to_users.exs
def change do
  alter table(:users) do
    add :company, :string
  end
end

Add the field to the typed_schema in user.ex:

elixir
typed_schema "users" do
  # ... existing fields ...
  field :company, :string
end

Add it to the appropriate changeset (use profile_changeset for user-editable fields, admin_changeset for admin-editable fields):

elixir
def profile_changeset(user, attrs \\ %{}) do
  user
  |> cast(attrs, [:name, :avatar, :is_onboarded, :company])
  |> validate_name()
end

Then add the form input to the relevant LiveView template.


2. Adding a New Page to the Sidebar

Files to touch:

  • lib/petal_pro_web/live/ — new LiveView module
  • lib/petal_pro_web/router.ex — route
  • lib/petal_pro_web/menus.ex — menu item

Create the LiveView:

elixir
# lib/petal_pro_web/live/projects_live.ex
defmodule PetalProWeb.ProjectsLive do
  use PetalProWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.layout current_page={:projects} current_user={@current_user} type="sidebar">
      <.container max_width="lg">
        <.page_header title="Projects" />
      </.container>
    </.layout>
    """
  end
end

Add the route inside the authenticated live_session block in router.ex:

elixir
live_session :authenticated, on_mount: [...] do
  # ... existing routes ...
  live "/projects", ProjectsLive
end

Add the menu item in menus.ex:

elixir
# 1. Add to sidebar_menu_items list
def sidebar_menu_items(current_user, current_page) when not is_nil(current_user) do
  build_menu(
    [:dashboard, :orgs, :projects, :subscribe, ...],
    current_user
  )
end

# 2. Add the get_link clause
def get_link(:projects = name, _current_user) do
  %{
    name: name,
    label: gettext("Projects"),
    path: ~p"/app/projects",
    icon: "hero-folder"
  }
end

3. Adding a New Admin Page

Files to touch:

  • lib/petal_pro_web/live/admin/ — new admin LiveView
  • lib/petal_pro_web/routes/admin_routes.ex — admin route
  • lib/petal_pro_web/menus.ex — admin menu item

Admin routes live in admin_routes.ex under the /admin scope, which already requires :require_admin_user:

elixir
# lib/petal_pro_web/routes/admin_routes.ex
live_session :require_admin_user,
  on_mount: [{PetalProWeb.UserOnMountHooks, :require_admin_user}] do
  # ... existing admin routes ...
  live "/reports", AdminReportsLive
end

Your LiveView uses the :admin layout type:

elixir
defmodule PetalProWeb.AdminReportsLive do
  use PetalProWeb, :live_view

  @impl true
  def render(assigns) do
    ~H"""
    <.layout current_page={:admin_reports} current_user={@current_user} type="admin">
      <.container max_width="lg">
        <.page_header title="Reports" />
      </.container>
    </.layout>
    """
  end
end

Add the admin menu link in menus.ex following the existing admin menu pattern.


4. Adding a New Oban Worker

Files to touch:

  • lib/petal_pro/workers/ — new worker module
  • config/config.exs — cron schedule (if recurring)

Create the worker:

elixir
# lib/petal_pro/workers/weekly_digest_worker.ex
defmodule PetalPro.Workers.WeeklyDigestWorker do
  @moduledoc "Sends a weekly digest email to all active users."
  use Oban.Worker, queue: :default, max_attempts: 3

  @impl Oban.Worker
  def perform(_job) do
    # Your logic here
    :ok
  end
end

For scheduled/cron jobs, add to the Oban.Plugins.Cron config in config/config.exs:

elixir
{Oban.Plugins.Cron,
 crontab: [
   # ... existing workers ...
   {"0 9 * * MON", PetalPro.Workers.WeeklyDigestWorker}
 ]}

For on-demand jobs (triggered by user action), enqueue from your context:

elixir
%{user_id: user.id}
|> PetalPro.Workers.WeeklyDigestWorker.new()
|> Oban.insert()

Workers run in the default or billing queue (both have 5 concurrent workers). Add a new queue in config.exs if needed: queues: [default: 5, billing: 5, emails: 10].


5. Scoping a Resource Under Organizations

Files to touch:

  • Migration — add org_id foreign key
  • lib/petal_pro/ — context module with org-scoped queries
  • lib/petal_pro_web/router.ex — org-scoped routes

Migration:

elixir
def change do
  create table(:projects, primary_key: false) do
    add :id, :binary_id, primary_key: true
    add :name, :string, null: false
    add :org_id, references(:orgs, type: :binary_id, on_delete: :delete_all), null: false
    timestamps(type: :utc_datetime)
  end

  create index(:projects, [:org_id])
end

Context with org scoping:

elixir
defmodule PetalPro.Projects do
  import Ecto.Query
  alias PetalPro.Repo

  def list_projects(%PetalPro.Orgs.Org{} = org) do
    Project
    |> where(org_id: ^org.id)
    |> Repo.all()
  end
end

Routes go inside the existing scope "/org/:org_slug" block in router.ex:

elixir
scope "/org/:org_slug" do
  # ... existing org routes ...
  live "/projects", OrgProjectsLive, :index
  live "/projects/new", OrgProjectsLive, :new
  live "/projects/:id", OrgProjectShowLive, :show
end

The org is loaded automatically by PetalProWeb.OrgOnMountHooks and available as @current_org in your LiveView assigns.


6. Adding an OAuth Provider

Files to touch:

  • mix.exs — add Ueberauth strategy dependency
  • config/config.exs — configure the provider
  • config/dev.exs, config/runtime.exs — API keys

Petal Pro uses Ueberauth with Google and GitHub pre-configured. To add another provider (e.g., Twitter):

Add the dependency:

elixir
{:ueberauth_twitter, "~> 0.4"}

Add to the providers list in config/config.exs:

elixir
config :ueberauth, Ueberauth,
  providers: [
    google: {Ueberauth.Strategy.Google, [default_scope: "email profile"]},
    github: {Ueberauth.Strategy.Github, [default_scope: "user:email"]},
    twitter: {Ueberauth.Strategy.Twitter, []}
  ]

Add API credentials in config/runtime.exs:

elixir
config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
  consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
  consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")

The existing auth callback controller (UserOauthController) handles the Ueberauth callback generically — it should work for any provider that returns an email. Check the callback/2 function if your provider returns data in a non-standard shape.


7. Adding a New Email Template

Files to touch:

  • lib/petal_pro_web/notifications/email.ex — email function
  • lib/petal_pro_web/templates/email/ — HEEx template
  • lib/petal_pro_web/controllers/email_testing_controller.ex — preview

Add the email function in PetalPro.Email:

elixir
def welcome_email(email, name) do
  base_email()
  |> to(email)
  |> subject("Welcome to the app!")
  |> render_body("welcome_email.html", %{name: name})
  |> premail()
end

Create the template at lib/petal_pro_web/templates/email/welcome_email.html.heex:

heex
<.heading>Welcome, <%= @name %>!</.heading>
<.text>Thanks for signing up. Here's what to do next...</.text>
<.button url="https://yourapp.com/app">Get Started</.button>

Email components (.heading, .text, .button, etc.) are defined in lib/petal_pro_web/components/email_components.ex.

Add to the email testing controller for previewing at /dev/emails:

elixir
# In @email_templates list:
@email_templates [
  # ... existing templates ...
  "welcome_email"
]

# Add generate_email clause:
defp generate_email("welcome_email", current_user) do
  Email.welcome_email(current_user.email, current_user.name)
end

Send the email:

elixir
PetalPro.Email.welcome_email(user.email, user.name)
|> PetalPro.Mailer.deliver()

8. Adding a Webhook Endpoint

Files to touch:

  • lib/petal_pro_web/controllers/ — new controller
  • lib/petal_pro_web/router.ex — webhook route

Petal Pro already has a webhook pipeline and scope. Look at ResendWebhookController for the pattern.

Create the controller:

elixir
# lib/petal_pro_web/controllers/my_service_webhook_controller.ex
defmodule PetalProWeb.MyServiceWebhookController do
  use PetalProWeb, :controller
  require Logger

  plug :verify_signature

  def handle(conn, %{"event" => "payment.completed"} = params) do
    Logger.info("[MyService] Payment completed: #{inspect(params)}")
    # Process the event...
    send_resp(conn, 200, "ok")
  end

  def handle(conn, %{"event" => event}) do
    Logger.info("[MyService] Unhandled event: #{event}")
    send_resp(conn, 200, "ok")
  end

  defp verify_signature(conn, _opts) do
    secret = PetalPro.config(:my_service_webhook_secret)

    if secret in [nil, ""] do
      conn
    else
      # Verify using your service's signing method.
      # Use conn.private[:raw_body] for the raw request body
      # (cached by CacheBodyReader, configured in endpoint.ex).
      raw_body = conn.private[:raw_body]
      signature = conn |> get_req_header("x-signature") |> List.first()

      expected = :crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower)

      if Plug.Crypto.secure_compare(signature || "", expected) do
        conn
      else
        conn |> send_resp(401, "Invalid signature") |> halt()
      end
    end
  end
end

Add the route in the existing webhook scope in router.ex:

elixir
scope "/webhooks", PetalProWeb do
  pipe_through [:webhook]
  post "/resend", ResendWebhookController, :handle
  post "/my-service", MyServiceWebhookController, :handle
end

The :webhook pipeline only accepts JSON (plug :accepts, ["json"]). Raw body caching is handled by CacheBodyReader in endpoint.ex, so conn.private[:raw_body] is available for signature verification.