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.exoradmin_user_live)
Steps:
Generate a migration:
mix ecto.gen.migration add_company_to_users
# 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:
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):
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:
# 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:
live_session :authenticated, on_mount: [...] do
# ... existing routes ...
live "/projects", ProjectsLive
end
Add the menu item in menus.ex:
# 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:
# 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:
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:
# 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:
{Oban.Plugins.Cron,
crontab: [
# ... existing workers ...
{"0 9 * * MON", PetalPro.Workers.WeeklyDigestWorker}
]}
For on-demand jobs (triggered by user action), enqueue from your context:
%{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_idforeign key -
lib/petal_pro/— context module with org-scoped queries -
lib/petal_pro_web/router.ex— org-scoped routes
Migration:
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:
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:
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:
{:ueberauth_twitter, "~> 0.4"}
Add to the providers list in config/config.exs:
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:
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:
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:
<.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:
# 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:
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:
# 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:
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.