# Petal Pro Documentation
# https://petal.build/petal-pro
# This file contains all Petal Pro documentation concatenated for LLM consumption.
# Individual pages available at /petal-pro/raw/{path}
# Page index available at /petal-pro/docs.json
# What is Petal Pro?
# Source: https://petal.build/petal-pro
# What is Petal Pro?
[Petal Pro](https://petal.build) is a Phoenix boilerplate template with the goal of drastically reducing your time from a new project to a production-ready web application.
We've worked on web applications for over a decade now. PHP, Ruby on Rails, Backbone JS, React, Meteor JS, Ember JS, and now Phoenix. Having built and deployed a number of Phoenix apps into the wild ([Kindly](https://www.kindlyapp.com.au/), [Travel Massive](https://www.travelmassive.com/) and [Talentbookr](https://talentbookr.com.au/)) we realised that the `mix phx.new` generator it produces a very lean web application and leaves a lot of decision-making to the developer.
To the more experienced developer, this can be liberating, however, it's also great to have more of a turnkey solution with a sensible set of defaults that get you fully up and running. A solution that, out of the box provides:
- a component library so you don't have to think too much about the design
- working authentication and user management so users can register and change their settings right away
- admin functionality allowing the basic management of users
- a way of logging user activity so you can see how users use the application
- some email templates that are ready to go and easily editable
- a cheap and reliable email service provider chosen for you (but easily changeable) to send transactional emails
- a CRUD generator that produces a clean and deployable interface
- a way to deploy to production in less than 10 minutes
Having not found the above solution, me and my business partner Nic decided to build it - the result is Petal Pro.
ℹ️ Information:Not an Elixir developer?: Phoenix web apps now compete with Javascript single-page applications thanks to its impressive web socket abilities via LiveView. We hope more Javascript developers will see the immense time-saving abilities of using Phoenix (along with Petal Pro). Imagine having a web application that feels like a SPA, but is all server-side code. You still can create reusable components and don't need to worry about an API. It really can cut your development time in half.
## What's included in v4
v4 is the biggest release yet. Here's a summary of what's new on top of the existing foundation:
### GDPR compliance
A "Your Data" settings page (`/app/users/your-data`) lets users download all their personal data as a JSON file and submit account deletion requests. Deletions run as background jobs via Oban, with soft-delete and anonymization cascade across related records.
### AI admin chat (Jido-powered)
The admin chat interface is now powered by [Jido](https://hex.pm/packages/jido) with a clean tool behaviour pattern. Built-in tools cover user management, org management, changelog operations, and AI cost stats. Adding your own tools is straightforward — implement the `PetalPro.Ai.Tool` behaviour and register in the tool registry.
### Admin MCP server
Connect Claude Code or Claude Desktop directly to your running app. The MCP server at `POST /api/mcp` exposes admin tools via JSON-RPC 2.0 with bearer token auth. Set `MCP_ADMIN_TOKEN` and add the server to your `.mcp.json` — then your AI assistant can query users, manage orgs, publish changelog entries, and more without leaving your editor.
### Per-type notification preferences
Users can now control in-app and email delivery independently for each notification type. Security-critical notifications (like password change alerts) are forced-on and cannot be disabled. Use `Notifications.notify/3` as the single entry point — it handles preference checks automatically before dispatching.
### Rate limiting and error tracking
Auth endpoints are now protected by Hammer-based rate limiting out of the box. Sentry error monitoring auto-activates when you set the `SENTRY_DSN` environment variable — no code changes needed.
### Production Dockerfile
A production-ready Dockerfile is included for containerized deployments. No more rolling your own from scratch.
### 22 Claude Code recipe commands
`.claude/commands/recipes/` ships with 22 slash commands for common customizations: removing billing, switching auth methods, adding integrations, structural changes, and more. Run `/recipes:help` to see the full list.
---
# Changelog
# Source: https://petal.build/petal-pro/changelog
# Changelog
A history of changes to Petal Pro
### 4.3.0 - 2026-06-24
#### Added
- **Private S3 buckets.** Set `private: true` in your `:s3` config and uploads stay out of public view. `consume_uploaded_entries/2` stores just the object key, and `PetalPro.FileUploads.download_url/1` returns a short-lived presigned GET URL when you display the file. It is a no-op for public buckets and other providers, so you can call it unconditionally in your templates.
- **Cloudflare R2 and Fly Tigris support.** Point the new `endpoint` config at R2, Tigris, or any S3-compatible service and uploads, downloads, and deletes all run through the same presigned path. Both ride on the existing S3 provider.
- **File deletion across providers.** Every upload provider now implements `delete_file/1`, and a user's old avatar is removed from storage automatically when they replace or clear it.
- **SAML SSO recipe.** A new `/recipes:add-saml-sso` walkthrough for wiring up enterprise identity providers (Okta, Microsoft Entra, Google Workspace), with the security prerequisites spelled out.
---
### 4.2.0 - 2026-06-08
#### Changed
- **Upgraded to Petal Components v4.** Petal Components dropped Alpine.js (every component is now `Phoenix.LiveView.JS` only) and ships its own JS. `assets/js/app.js` now imports the `petal_components` bundle and merges its hooks into the LiveSocket - these power the password / copyable / clearable inputs, the accordion, and the Chat components. Alpine.js stays for Petal Pro's own components (sidebar persistence etc.), so nothing else changes for you. The `js_lib` attribute was removed from `<.dropdown>`, `<.accordion>`, and the menu components and now raises if passed - if you've customised a call site that passes `js_lib`, drop it. See the [Petal Components v4 upgrade guide](https://hexdocs.pm/petal_components/upgrade.html).
---
### 4.1.0 - 2026-05-21
#### Added
- **WebAuthn passkey authentication** - register up to 5 passkeys (Face ID, Touch ID, hardware keys) from your security settings. Two modes: primary auth (passwordless sign-in) and second factor (2FA on top of password). Includes cloned authenticator detection via sign count validation and a guard against passkey 2FA bypass in `require_authenticated_user/2`
- **Persistent admin chat history** - AI admin chat conversations are now stored in the DB. They survive reloads and restarts, and the sidebar lists recent conversations so you can pick up where you left off
- **`mix quality` now runs `credo --strict` and `dialyzer`** - fresh clones pass both out of the box. Existing projects will need to clean up any strict credo violations and get dialyzer running
#### Fixed
- Passkey sign-in session establishment before redirect
- Cloned authenticator detection via sign count validation
- Passkey 2FA bypass guard in `require_authenticated_user/2`
---
### 4.0.0 - 2026-05-04
#### Added
- **GDPR compliance** — "Your Data" settings page (`/app/users/your-data`) lets users download all personal data as JSON and submit account deletion requests with soft-delete, anonymization cascade, and Oban background processing
- **Per-type notification preferences** — users can toggle in-app and email channels independently per notification type; security-critical notifications (e.g. password changes) are forced-on and cannot be disabled
- **`Notifications.notify/3` entry point** — single dispatch function that checks per-user, per-type preferences before sending in-app or email notifications
- **Welcome notification** on registration and password change alert on password update
- **Admin MCP server** — exposes admin tools to external AI clients (Claude Code, Claude Desktop) via JSON-RPC 2.0 at `POST /api/mcp` with bearer token auth; set `MCP_ADMIN_TOKEN` env var
- **AI cost tracking** — Req plugin that automatically logs every AI API call with token counts, costs, and timing to the `ai_call_logs` table
- **Admin chat tools** — `ManageUser` (suspend/unsuspend/delete/restore/promote), `ListOrgs`, `ManageOrg` (rename/delete/members), and `GetAiCallStats`
- **Changelog MCP tools** — `list_changelog_updates`, `manage_changelog`, and `create_changelog_update` for AI admin chat and external MCP clients
- **In-app feedback system** — users can submit feedback from any page; admin dashboard for reviewing and managing submissions
- **Sentry error monitoring** — auto-activates when `SENTRY_DSN` env var is set; no code changes required
- **Hammer-based rate limiting** — applied to auth endpoints via plugs
- **Resend email adapter** — set `RESEND_API_KEY` to use Resend for transactional email via Swoosh
- **Production Dockerfile** — ready-to-use containerized deployment
- **Generic JS hooks collection** — infinite scroll, scroll to bottom, scroll to hash, select all, share
- **Email component enhancements** — configurable gap, gray button variant, and callout boxes
- **Dev scripts** — `server.sh`, `reset_db.sh`, `kill_db.sh` for common development tasks
- **Lightweight page view analytics** — bot filtering and daily aggregation
- **Daily Slack digest worker** — activity summaries via Oban cron
- **Slow query telemetry logging** — configurable threshold for dev environment
- **Interactive DB schema visualizer** — dev page with Mermaid ER diagrams (pan/zoom), domain-grouped tables, column details with PK/FK indicators
- **Hierarchical route tree** — collapsible tree view mirroring URL path structure with verb badges and click-to-copy helpers
- **22 AI-powered recipe commands** (`.claude/commands/recipes/`) — Claude Code slash commands for common customizations: auth, billing, feature removal, integrations, structural changes
- **Claude Code sub-agents** — `schema-architect`, `test-runner`, `elixir-reviewer`, `heex-reviewer`, `ui-ux-reviewer`
- **Claude Code skills and commands** — CI, commit, review, changelog, scaffolding workflows
- **COMPONENTS.md** — reference document cataloging all available components and JS hooks
- **CLAUDE.md project instructions** — scoped per directory for in-repo AI context
#### Updated
- **Jido AI for admin chat** — replaced LangChain with native `Jido.Action` modules under `PetalPro.AI.AdminChat.Actions.*`, registered in `ActionRegistry` and shared by both the chat LiveView and the MCP server
- **Req for all HTTP calls** — replaced HTTPoison and Tesla throughout
- **Sidebar-only layout** — converted to sidebar-only; no desktop header; integrated user profile and notification badge
- **Styler upgraded from 0.11 to 1.11** — run `mix format` after upgrading
- **Changelog context** — restructured `ChangelogUpdates` → `Changelog` with proper schema naming (`Update`, `Like`, `Read`), Flop DataTable with pagination/sorting/filtering on admin index, shared `ChangelogHelpers` module, comprehensive test coverage, and SEO meta descriptions on public pages
- **Modularized seed system** — split into production and development modules
- **Fly.io deploy** — now triggers on push to main
- **NPM packages updated** — including ESbuild, with updated `esbuild.js`
- **Sidebar collapse button** — redesigned as flush edge tab with directional chevrons; deferred transitions prevent flash on load
- **Updated deps** — `phoenix_live_view 1.1.27`, `petal_components 3.0.2`, `jido 2.1`, `sentry 12.0.3`, `swoosh 1.23.1`
#### Fixed
- Wallaby test race condition in signup flow
---
### 3.0.0 - 2025-07-22
- Upgrades Petal Pro to Tailwind 4!
- New Pro components - Aurora and BorderBeam
- Adds support for Stripe metered usage
- Replaces Admin Jobs with Oban Web
- Adds `TailwindFormatter`
- Port is configurable for the test environment
- Updated: Adds `mix assets.build`
- Updated: Combobox will warn if `:tom_select_options_global_variable` includes load handler and `:remote_options_event_name` is set
- Fixed: SubscribeController redirects and shows Stripe error
- Fixed: Authenticated views no longer auto-subscribe to user notifications
- Fixed: Adds missed exception for blog route
- Fixed: Seeder clears posts/files properly
- Fixed: Post/File is based on PetalPro.Schema
- Fixed: Combobox does not disappear when navigating to another page
#### Upgrade Notes
Petal Pro has been upgraded to Tailwind 4. Some utilities have been removed or renamed. See the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide) for more information.
To upgrade to Tailwind 4 in your project, make sure you update `mix.exs`:
```diff
- {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}
+ {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}
```
And `config.exs` (note that the paths have changed and that you need 4.0.9 or above):
```diff
config :tailwind,
- version: "3.3.3",
+ version: "4.1.11",
default: [
args: ~w(
- --config=tailwind.config.js
- --input=css/app.css
- --output=../priv/static/assets/app.css
+ --input=assets/css/app.css
+ --output=priv/static/assets/app.css
),
- cd: Path.expand("../assets", __DIR__)
+ cd: Path.expand("..", __DIR__)
]
```
Don't forget to run:
```shell
mix tailwind.install
```
#### Updated app.css
`app.css` has been updated to support CSS first configuration:
app.css
```elixir
@import "tailwindcss";
@source "../../deps/petal_components/**/*.*ex";
@import "../../deps/petal_components/assets/default.css";
@import "./colors.css";
@import "./combo-box.css";
@import "./editorjs.css";
@import "./animations.css";
@import "../node_modules/tippy.js/dist/tippy.css" layer(components);
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/aspect-ratio";
@plugin "./tailwind_heroicons.js";
...
```
To see the latest changes for 3.0.0, go to:
[https://github.com/petalframework/petal_pro/tree/v3.0.0/assets/cssgithub.com](https://github.com/petalframework/petal_pro/tree/v3.0.0/assets/css)
The following files have changed:
- `app.css`
- `combo-box.css`
- `editorjs.css`
The following files have been added:
- `colors.css`
- `animations.css`
- `tailwind_heroicons.js`
Finally, `tailwind.config.js` has been removed.
Now you should be able to follow the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide) to update the rest of your project. Running `mix format` should automatically arrange tailwind classes in your project.
### 2.2.0 - 2024-12-19
- Upgrades Petal Pro to LiveView 1.0.1!
- Admin/User AI chat - use LangChain to interrogate Petal Pro
- Content Editor component - based on Editor.js
- Blog/Content Management System - leveraging Content Editor
- Updated: Using latest Heroicons from Petal Components 2.0.6
- Updated: address gettext deprecation issue
- Updated: `petal.gen.live` uses Table instead of Data Table (and is based on streams). Use the `--data-table` option to generate templates using a Data Table
- Updated: Compatability increased for code generated by `phx.gen.live` and `phx.gen.html`
- Fixed: `sticky` class for stacked layout
- Fixed: Sub-menu will display for Stacked Layout in mobile mode
- Fixed: User notifications marked as read based on `socket` url (previously only worked with `conn` request path)
- Fixed: Combobox has rounded corners after selection
#### Upgrade Notes
In Petal Components, the `heroicons` dependency has been replaced with an alternate CSS-based approach. This is based on what has been implemented in the Phoenix repo - the reasoning is explained in this [issue](https://github.com/phoenixframework/phoenix/issues/5622). To upgrade an existing Petal Pro project, add the dependency in `mix.exs`:
mix.exs
```diff
def deps do
[
+ {:heroicons,
+ github: "tailwindlabs/heroicons",
+ tag: "v2.1.5",
+ app: false,
+ compile: false,
+ sparse: "optimized"},
]
end
```
This will download a copy of the source SVG files directly from GitHub (into `deps/heroicons/optimized`). Then the following code will process these files to create custom CSS classes:
assets/tailwind.config.js
```diff
const colors = require("tailwindcss/colors");
const plugin = require("tailwindcss/plugin");
+ const fs = require("fs");
+ const path = require("path");
module.exports = {
plugins: [
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
],
};
```
### 2.0.0 - 2024-08-15
- Petal Framework is merged into Petal Pro
- Npm packages recipe is merged in as default
- Persistent user notifications
- Graphs/stats dashboard in the Admin console
- See who's currently active (via Presence) in the Admin console
- If user belongs to only one org, current org is automatically set
- `User.is_admin` is replaced with `User.role`. User role is an enum
- Integrate `:typed_ecto_schema` with `Ecto.Schema` fallback for users who have not installed it.
- Orgs are added to log view
- Ecto insights tab is enabled in LiveDashboard
- Fixed: Edge case for Combo Box - Tom Select is re-generated when there is a change in option count
- Fixed: App does not crash when impersonating a user and the session has expired
- Fixed: Subscription logs weren't working!
- Fixed: Race condition with subscription create/update when responding to Stripe web hooks
- Fixed: In docker compose, postgres container will not auto-restart if stopped
#### Upgrade Notes
Starting with this release, the Petal Framework is no longer referenced as an external package. If you have generated a Dockerfile for deployment, then you can remove the following section:
Dockerfile
```diff
# Remove the Petal repo:
- RUN --mount=type=secret,id=PETAL_LICENSE_KEY \
- mix hex.repo add petal https://petal.build/repo \
- --fetch-public-key "SHA256:6Ff7LeQCh4464psGV3w4a8WxReEwRl+xWmgtuHdHsjs" \
- --auth-key $(cat /run/secrets/PETAL_LICENSE_KEY)
```
You may need to add `nodejs` and `npm`:
Dockerfile
```diff
# install build dependencies
+ RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
+ && apt-get clean && rm -f /var/lib/apt/lists/*_*
```
In the past `mix assets.setup` would install Tailwind CSS and esbuild. Tailwind is now installed with `mix setup`, esbuild is installed via npm with `mix assets.deploy`. You'll need to remove:
Finally, `lib/petal_pro_web/petal_framework_translations.ex` is no longer required. Components like the DataTable can use Gettext directly.
### 1.8.0 - 2024-04-04
- JSON API: You can use the Petal Pro API for user management (register, sign in and update). It comes with OpenAPI support out of the box. Includes Swagger UI
- Update: Can sync all Stripe subscriptions including cancelled ones
- Added expanded user and org admin pages
- Fixed: PetalProWeb.Controllers.UserAuth.disconnect_user_liveviews doesn't disconnect the user's live views #216
- Fixed: Passwordless sign in issue from latest Phoenix Live View fixed #222
- Update: Sidebar layout now collapsible
### 1.7.0 - 2024-11-01
- Stripe integration - can buy subscriptions and manage them - support for both individuals and orgs
- Use `styler` for code formatting
- Updated landing page components to a more modern look
#### Upgrade Notes
Starting in this release, we now use [Styler](https://github.com/adobe/elixir-styler) to format our codebase. This means there will be a large amount of changes. To minimise merge or rebase conflicts when upgrading, we recommend you swap out Recode with Styler first:
Then, replace the content of `.credo.exs` with the one in this repo.
Finally, run the formatter for Styler to take care of things:
Another change we recommend you make before upgrading, especially if you've made changes to your landing page, is to rename `PetalProWeb.Components.LandingPage` to `PetalProWeb.LandingPageComponents`, make sure you also rename the file from `landing_page.ex` to `landing_page_components.ex`.
Given the amount of changes in this release, we recommend you backup your branch, then use `merge` instead of `rebase` to bring your branch up to date.
### 1.6.2 - 2023-11-03
- Fix bug where users registering via passwordless / 3rd parties would not get invites to orgs
### 1.6.1 - 2023-9-28
- Important passwordless security fix - anyone using passwordless should update
- Update deps
### 1.6.0 - 2023-7-05
#### New
- <.image_upload> component for uploading single images like avatars. Used in user settings
- Local, Cloudinary and S3 support for <.image_upload>
- User impersonation - admins can login as any user without knowing their password - available via the Admin console (under the User menu).
#### Updated
- All forms now use the new FormField structure
- <.public_layout> has been moved into public.html.heex (it's likely you'll want to modify this to your brand and hence a component doesn't make sense)
#### Fixed
- Reset password page now accessible when logged in (previously would redirect)
#### Removed
- Page builder (it was getting too complex to manage - let us know if you used this a lot and we'll consider bringing it back)
### 1.5.2 - 2023-4-20
#### New
- Uses the `petal_framework` private package (deletes all the `_petal_framework` folders). This will allow for an easier upgrade process in the future, especially for components like the data table.
- Data Table supports `base_url_params`. Keep custom query params when changing sort order.
- Data Table is now self contained in the live view - code isn't scattered across the model and context.
- Added `mix recode` for code linting and refactoring
- Use `<.flash_group>` to show flash messages (Petal Framework 0.3.0)
#### Changes
- Enabled Wallaby tests by default (`mix test`)
- Updated Tailwind to 3.3
- Updated Erlang to 25.3
- Added `rename_project` lib to replace custom mix task
#### Breaking
- Data table API has changed slightly. See our docs https://petal.build/components/data-table on how to use
#### Fixed
- Data table going blank when changing page size
- Navigation problems for the Data Table when setting the `default_limit` via a schema file
- Fix Finch issue with email sending
### 1.5.1 - 2023-3-10
#### New
- Develop Petal Pro in GitHub CodeSpaces!
- Data Table column - can now filter by a select list
- Data Table column - can now filter by a float
#### Changed
- topbar.js version updated to 2.0
- mix setup no longer fetches tailwind/esbuild if they already exist
- Upgrade Petal Components to 1.0
- Data Table is now a function component
#### Fixed
- Github auth working again
### 1.5.0 - 2023-2-10
#### Changed
- Upgraded Phoenix to 1.7
- Routes use the new verified routes
- Authentication pages converted to live views
- Upgraded fully from Hericons v1 to v2
- `petal.gen.live` now uses the Data Table component
- Tesla now uses the more secure Finch over Hackney (https://github.com/petalframework/petal_pro/issues/66)
- Confirmation page will redirect to org invitations if invitation exists (https://github.com/petalframework/petal_pro/issues/68)
- Removed Petal Enhance (it was more complex than we thought)
#### Fixes
- Redirect to `/app/orgs` if an invalid org slug is used (https://github.com/petalframework/petal_pro/issues/70)
- When editing user via `/admin/users` - `patch_back_to_index` no longer crashes (https://github.com/petalframework/petal_pro/issues/61)
- Always show Data Table filters (https://github.com/petalframework/petal_pro/issues/60)
### 1.4.1 - 2022-11-15 16:30:00
#### Added
- Add Petal Enhance (recipes)
#### Fixes
- Fix admin users modal. Clicking off the modal now works
- Change avatar_url to text in DB for long URLs
- Whitelist Google profile picture images in CSP
- Fix Mailbluster unsubscribe route + documentation
### 1.4.0 - 2022-10-12 23:44:20
#### Added
- Updated to LiveView 0.18 + Petal Components 0.18
- All components updated with declarative assigns (attr / slot)
- Data table component
- Local time component
- Sobelow for security checking
- Coveralls for code test coverage
- test.yml Github action for code quality
- Easily modifiable content security policy to help prevent XSS
- Added a docker-compose.yaml that adds Postgres so you don't need to install it
#### Changed
- Router significantly more streamlined
- Some routes have been moved into their own files: AdminRoutes, AuthRoutes, DevRoutes, MailblusterRoutes
- Users are forced to be confirmed to access /app routes (easily configurable)
- Use Ecto enums for org roles
#### Fixes
- Fix reset password failing when signed in
- Clean up dashboard_live.ex - some old code unused was left in there
- Improved SEO meta tags in _head.html.eex
- Show a warning in the logs when no title/meta_desciprion is set on a public page
- Added `open-graph.png` (to be replaced by dev)
- Fix require_confirmed_user plug
- Fix landing page GSAP not working
### 1.3.0 - 2022-06-17 02:43:55
#### Added
- Two-factor authentication using time-based one time passwords (paired with something like Google Authenticator)
#### Changed
- Decoupled DashboardLive from Orgs so you can get started quicker if you don't want orgs
- Can pass a custom header class to the public layout
- Sidebar and stacked layouts now support grouped menu items (see dev_layout_component.ex for an example)
- Update Tailwind to 3.1
- Split CSS into different files thanks to Tailwind 3.1
#### Fixed
- Onboarding now remembers user_return_to
- Fixed nav dropdown bug after modal toggle
- Fixed gettext in live views
### 1.2.0 - 2022-05-31 07:34:11
#### Added
- Login with Google & Github - easy to add more auth providers
- Passwordless auth - register/sign in via a code sent to your email
- Orgs - create an org, invite & manage members, and more
- User lifecycle actions - run code after actions like register, sign_in, sign_out, password_reset, etc
- New generator: mix petal.gen.html (same args as phx.gen.html)
- New component: <.markdown content=""> & <.pretty_markdown content=""> (uses Tailwind Typography plugin)
- Added License and Privacy pages (with some content from a template to get you started)
- New layout: <.layout type="public">, for public marketing related pages like landing, about us, privacy, etc
- Hooks can now be run in dead views if compatible (see color-scheme-hook.js as an example)
#### Changed
- Simpler config access (`PetalPro.config(:app_name)` instead of `Application.get_env(:petal_pro, :app_name)`)
- Refactor <.layout> to take less props
- Refactor dark/light mode system. Much simpler now and no longer needs cookies
- Put Petal Pro Components in their own folder for easier future upgrades (can duplicate if you want to modify them)
- Sidebar and stacked layout have a new slot for the top right corner (if you want to add something like a notifications bell)
#### Fixed
- Log metadata wasn't being cast
- More user actions are logged
- Fixed petal.live generator tests
- Added tests for user settings live views
### 1.1.1 - 2022-03-12 20:45:36
- Bump Oban version to stop random error showing
- Bump Petal Components
- Use new <.table> component in petal.gen.live generator & logs
- Dark mode persists on all pages with cookies
- Fix logo showing twice in emails
- Improved the Fly.io deploy flow
- Fix admin user search
- Remove guide (this is now online)
### 1.1.0 - 2022-02-28 00:08:52
- Added generator `mix petal.gen.live`
- Add gettext throughout public facing templates
- Improved dev_guide
- Add Oban
- Easy Fly.io deployment
---
# Upgrade Guide
# Source: https://petal.build/petal-pro/upgrade-guide
# Upgrade guide
How to upgrade your PetalPro project to the latest versions
Version updates can be a bit of a manual process. A boilerplate is meant to be a starting point for you to build from - rather than a framework like Phoenix. However, it is possible.
### 4.3.0 -> 4.4.0
Petal Pro 4.4.0 moves up to petal_components 4.6. The headline: two components you used to get from Petal Pro, `aurora` and `border_beam`, now live in the free petal_components library. They got rebuilt on the way over (glow, multiple beams, spring easing), so Pro drops its own copies and consumes the free ones like every other component. `floating_div` is gone too, replaced by the free `popover`.
If you take 4.4.0 the normal way, most of this is handled for you. The one thing that will bite is bumping petal_components on an older Pro.
#### Take Pro 4.4.0 before you run `mix deps.update petal_components`
Pro 4.4.0 requires `petal_components ~> 4.6`. If you update petal_components first, while still on an older Pro, the build dies with:
```
error: function border_beam/1 imported from both PetalProWeb.BorderBeam and PetalComponents.BorderBeam, call is ambiguous
```
Both libraries now export `border_beam/1`, both are in scope, so any unqualified call is ambiguous. `aurora/1` is the same problem but quieter: Pro never calls `<.aurora>` itself, so the build stays green and it only breaks the first time you use `<.aurora>` in your own templates.
The fix is Pro 4.4.0, which deletes Pro's copies and switches to the free ones. So the order is:
1. Bump your Petal Pro version to 4.4.0 first
2. Then `mix deps.get` pulls petal_components 4.6
If you never touched these two components, that is the whole job. If you customised them, you have a few attribute renames to do.
#### border_beam attribute renames
The free `border_beam` styles its own panel now (surface, border, radius), so on top of the renames you can drop the background, border and radius classes you used to pass alongside it.
| Old (Pro) | New (free 4.6) | Notes |
|---|---|---|
| `gradient_color_start` | `color_from` | |
| `gradient_color_end` | `color_to` | |
| `animation_duration` | `duration` | |
| `beam_size` | `size` | |
| `offset_distance` | `initial_offset` | was a string like `"0%"`, now an integer |
| `border_color` | (removed) | the beam draws its own border |
| `border_radius` | `border_radius` | still accepted, but leave it off and the beam follows your `--pc-radius` token |
Pro's only `border_beam` call site is the auth card in `auth_layout.ex`. Here is the before and after so you can see the class cleanup:
```diff
-<.border_beam
- gradient_color_start="#3b82f6"
- gradient_color_end="#67e8f9"
- border_radius="0.75rem"
- animation_duration="10s"
- class="border-gray-200! shadow-gray-200/50 bg-white px-5 py-8 shadow-lg dark:border-gray-700/80! dark:bg-gray-800/80 dark:shadow-none dark:backdrop-blur-sm sm:rounded-xl sm:px-10"
->
+<.border_beam
+ color_from="#3b82f6"
+ color_to="#67e8f9"
+ duration="10s"
+ class="shadow-gray-200/50 px-5 py-8 shadow-lg dark:shadow-none sm:px-10"
+>
{render_slot(@inner_block)}
```
#### aurora attribute renames
| Old (Pro) | New (free 4.6) | Notes |
|---|---|---|
| `invert` | `invert` | was a boolean, now a string: `"auto"`, `"none"`, or `"always"` |
| `animation_duration` | `speed` | |
| `background_gradient` | (removed) | pass a `colors` list instead |
| `aurora_gradient` | (removed) | pass a `colors` list instead |
| `opacity` | `opacity` | unchanged |
| `blur` | `blur` | unchanged |
| `mask_position` | `mask_position` | unchanged |
| `mask_coverage` | `mask_coverage` | unchanged |
#### Careful: unknown attributes fail silently
petal_components components pass anything they don't recognise straight through to the DOM as a plain HTML attribute. So an old call like `<.border_beam gradient_color_start="#3b82f6">` will not error after you switch to the free component. It renders a `gradient_color_start` attribute on the div, the browser ignores it, and your beam still shows up looking wrong. No compile error, no crash, just a component that quietly stopped matching your design. Work through the rename tables so you don't ship one.
#### floating_div is gone
`floating_div` had no call sites left in Pro, so 4.4.0 deletes it along with its `@floating-ui/dom` npm dependency. If you used `<.floating_div>` in your own code you get a compile error on upgrade. Swap it for the free `<.popover>` in top-layer mode, which covers the same case (a panel that needs to escape a clipped or `overflow-hidden` container):
```heex
<.popover top_layer placement="bottom-start">
<:trigger>Open
Your floating content
```
#### Catch this in your own app
The whole thing came from Pro shipping a component with the same name as one the free library exports. You can hit it the same way in your own code: define a component named the same as a petal_components one and every unqualified call becomes ambiguous. `use PetalComponents` names are a reserved namespace.
This test walks both sides at runtime and fails if any of your components shadow a `use PetalComponents` export. Drop it into your test suite. Two things to change for your app: swap `:petal_pro` for your OTP app name, and `/petal_pro_web/components/` for wherever your components live (and adjust the `@exempt` list, or empty it).
```elixir
defmodule MyAppWeb.PetalComponentsCollisionTest do
use ExUnit.Case, async: true
# Modules that render without `use PetalComponents` (e.g. email templates
# on bare Phoenix.Component). Leave empty if you have none.
@exempt [MyAppWeb.EmailComponents]
@ignored_exports [:module_info]
defp generated?(name) do
name = Atom.to_string(name)
String.starts_with?(name, "__") and String.ends_with?(name, "__")
end
test "no component shadows a name exported by `use PetalComponents`" do
free = free_export_names()
assert MapSet.size(free) > 50,
"only found #{MapSet.size(free)} free exports - the import-block parser is probably broken"
offenders =
for mod <- app_component_modules(),
mod not in @exempt,
{name, arity} <- mod.module_info(:exports),
name not in @ignored_exports,
not generated?(name),
MapSet.member?(free, name) do
"#{inspect(mod)}.#{name}/#{arity}"
end
assert offenders == [], """
Reserved-namespace collision. These components define names that
`use PetalComponents` also exports, so any unqualified call is ambiguous:
#{Enum.map_join(offenders, "\n", &" - #{&1}")}
If the free library now ships this component, delete your copy and move the
call sites to the free one. If it is genuinely yours, rename it.
"""
end
defp free_export_names do
for mod <- free_import_block(),
Code.ensure_loaded?(mod),
{name, _arity} <- mod.module_info(:exports),
name not in @ignored_exports,
not generated?(name),
into: MapSet.new(),
do: name
end
defp free_import_block do
source =
Mix.Project.deps_paths()
|> Map.fetch!(:petal_components)
|> Path.join("lib/petal_components.ex")
|> File.read!()
[_, block] = Regex.run(~r/import PetalComponents\.\{(.+?)\}/s, source)
block
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&Module.concat(PetalComponents, &1))
end
defp app_component_modules do
{:ok, mods} = :application.get_key(:petal_pro, :modules)
Enum.filter(mods, fn mod ->
Code.ensure_loaded?(mod) and
function_exported?(mod, :__components__, 0) and
mod |> source_path() |> String.contains?("/petal_pro_web/components/")
end)
end
defp source_path(mod) do
to_string(mod.module_info(:compile)[:source])
rescue
_ -> ""
end
end
```
#### If you can't pull 4.4.0
If your membership has lapsed you can still fix this by hand, no repo access needed. You are deleting Pro's two component copies, dropping their imports, and porting the one call site.
1. Delete these two files:
```
lib/petal_pro_web/components/pro_components/aurora.ex
lib/petal_pro_web/components/pro_components/border_beam.ex
```
2. Remove these imports:
```diff
- import PetalProWeb.BorderBeam
```
3. Port the `border_beam` call in `auth_layout.ex` per the rename table (before and after above).
4. If you used `<.aurora>` anywhere, rename its attributes per the aurora table.
5. Now `mix deps.update petal_components` is safe.
That is the complete fix. The free components are a superset of what Pro shipped, so you lose nothing by switching.
---
### 4.0.x -> 4.1.0
#### New migrations
Run `mix ecto.migrate` after updating. Two new table groups are added:
- **WebAuthn passkeys** - `user_passkeys` table plus a `passkey_2fa_preferred` boolean column on the `users` table
- **Admin chat history** - `admin_chat_conversations` and `admin_chat_messages` tables for persistent AI chat
Both sets of migrations are additive - they won't touch your existing data.
#### WebAuthn passkeys (optional)
Passkeys let your users register Face ID, Touch ID, or hardware keys. There are two modes: passwordless primary auth, and 2FA on top of password login. If you don't want passkeys yet, skip this section - the migrations are still safe to run, the feature just won't be reachable.
To wire up passkeys in an existing project:
1. Add the new config keys to `config/config.exs`:
```elixir
config :petal_pro, :webauthn,
enabled: true,
rp_name: "Your App Name",
rp_id: "yourdomain.com",
origin: "https://yourdomain.com"
```
2. Copy the new passkey LiveViews from the 4.1.0 source into your project:
- `lib/petal_pro_web/live/auth/passkey_registration_live.ex`
- `lib/petal_pro_web/live/auth/passkey_login_live.ex`
- `lib/petal_pro_web/live/user_settings/passkey_settings_live.ex`
3. Add the new passkey routes to your router (copy from the 4.1.0 `router.ex`).
4. The security settings page gains a "Passkeys" section automatically once the routes are in place.
#### Persistent admin chat history (optional)
Admin chat conversations are now stored in the DB and survive reloads and restarts. The sidebar lists recent conversations. This is purely additive - just run the migrations and you're done, no config needed.
#### mix quality now runs credo --strict and dialyzer
This is the one change that may require some work on existing projects. Fresh clones of 4.1.0 pass both out of the box, but an existing project that's been customised will likely have some credo violations to clean up.
Steps:
1. Run `mix credo --strict` and fix any violations it finds
2. Get dialyzer running: `mix dialyzer` (first run builds the PLT, takes a few minutes)
3. Fix any dialyzer warnings that come up
Both tools are optional to run locally, but CI will run them via `mix quality` from 4.1.0 onwards. If you don't want strict credo or dialyzer in your CI, update your `mix quality` alias in `mix.exs` to match your preferences.
#### Upgrade steps
1. Update your Petal Pro version in `mix.exs`
2. Run `mix deps.get`
3. Run `mix ecto.migrate` (picks up the passkeys and admin chat tables)
4. Run `mix credo --strict` and fix violations
5. Run `mix dialyzer` and address any warnings
6. If you want passkeys: add the config, copy the LiveViews, add the routes (see above)
---
### 3.0 -> 4.0
#### Breaking changes
**Jido AI replaces LangChain for admin chat**
The admin chat system has been rewritten around [Jido](https://hex.pm/packages/jido). If you added custom LangChain tools, you'll need to migrate them to the Jido tool behaviour pattern.
Each tool is a module that implements the `PetalPro.Ai.Tool` behaviour:
```elixir
defmodule MyApp.Ai.Tools.MyTool do
@behaviour PetalPro.Ai.Tool
@impl true
def name, do: "my_tool"
@impl true
def description, do: "Does something useful"
@impl true
def parameters do
%{
type: "object",
properties: %{
user_id: %{type: "string", description: "The user's ID"}
},
required: ["user_id"]
}
end
@impl true
def execute(%{"user_id" => user_id}, _context) do
# your logic here
{:ok, "result"}
end
end
```
Register tools in your `PetalPro.Ai.Tools` registry module. Remove any LangChain dependencies from `mix.exs`.
**Req replaces HTTPoison and Tesla**
All HTTP calls now use [Req](https://hex.pm/packages/req). Replace any remaining HTTPoison or Tesla calls:
```diff
# mix.exs
- {:httpoison, "~> 2.0"},
- {:tesla, "~> 1.8"},
+ {:req, "~> 0.5"},
```
```elixir
# Before (HTTPoison)
HTTPoison.get!("https://example.com/api")
# After (Req)
Req.get!("https://example.com/api")
```
**`ChangelogUpdates` context renamed to `Changelog`**
The changelog context and its schema modules have been renamed:
```diff
- PetalPro.ChangelogUpdates
+ PetalPro.Changelog
- PetalPro.ChangelogUpdates.ChangelogUpdate -> PetalPro.Changelog.Update
- PetalPro.ChangelogUpdates.ChangelogLike -> PetalPro.Changelog.Like
- PetalPro.ChangelogUpdates.ChangelogRead -> PetalPro.Changelog.Read
```
Update any references in your own code. A new migration handles the underlying table renames.
**Styler upgraded from 0.11 to 1.11**
Styler 1.x applies additional formatting rules. After updating deps, run `mix format` — expect a large diff of auto-reformatted code. This is cosmetic only.
**Sidebar-only layout**
The desktop header layout has been removed. All authenticated pages now use the sidebar layout exclusively. If you have pages using a header-based layout, migrate them to `type="sidebar"`.
**Email provider simplified to Resend**
The email adapter is now Resend only. Set `RESEND_API_KEY` in your environment. Remove any SES or other adapter configuration.
#### New environment variables
| Variable | Required | Purpose |
|---|---|---|
| `RESEND_API_KEY` | Yes | Transactional email via Resend |
| `SENTRY_DSN` | No | Auto-activates Sentry error monitoring when set |
| `MCP_ADMIN_TOKEN` | Recommended | Bearer token for the admin MCP server at `POST /api/mcp` |
#### New migrations
Run `mix ecto.migrate` after updating. The new migrations cover:
- **GDPR tables** — `data_export_requests` and `data_deletion_logs` for the Your Data feature
- **Notification preferences** — `notification_preferences` table for per-type in-app/email toggles
- **AI cost tracking** — `ai_call_logs` table for token counts, costs, and timing
- **Changelog schema rename** — renames underlying tables from `changelog_updates`/`changelog_likes`/`changelog_reads` to `updates`/`likes`/`reads` within the `changelog` context
#### Recommended upgrade steps
1. Update `mix.exs` — bump Petal Pro version, swap LangChain/HTTPoison/Tesla for Jido/Req, update Styler to `~> 1.11`
2. Run `mix deps.get`
3. Run `mix ecto.migrate`
4. Run `mix format` — Styler 1.11 will reformat a lot of code; review the diff but don't worry about it
5. Update `RESEND_API_KEY` in your secrets/`.envrc`; remove old email adapter config
6. If you added custom LangChain tools: migrate them to the Jido tool behaviour pattern (see above)
7. If you used HTTPoison or Tesla directly: replace calls with `Req`
8. If you reference `PetalPro.ChangelogUpdates`: update to `PetalPro.Changelog` and new schema names
9. Optionally set `SENTRY_DSN` and `MCP_ADMIN_TOKEN` in production
---
### 2.2 -> 3.0
Petal Pro has been upgraded to Tailwind 4. Some utilities have been removed or renamed. See the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide) for more information.
To upgrade to Tailwind 4 in your project, make sure you update `mix.exs`:
```diff
- {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}
+ {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}
```
And `config.exs` (note that the paths have changed and that you need 4.0.9 or above):
```diff
config :tailwind,
- version: "3.3.3",
+ version: "4.1.11",
default: [
args: ~w(
- --config=tailwind.config.js
- --input=css/app.css
- --output=../priv/static/assets/app.css
+ --input=assets/css/app.css
+ --output=priv/static/assets/app.css
),
- cd: Path.expand("../assets", __DIR__)
+ cd: Path.expand("..", __DIR__)
]
```
Don't forget to run:
```shell
mix tailwind.install
```
#### Updated app.css
`app.css` has been updated to support CSS first configuration:
app.css
```elixir
@import "tailwindcss";
@source "../../deps/petal_components/**/*.*ex";
@import "../../deps/petal_components/assets/default.css";
@import "./colors.css";
@import "./combo-box.css";
@import "./editorjs.css";
@import "./animations.css";
@import "../node_modules/tippy.js/dist/tippy.css" layer(components);
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/aspect-ratio";
@plugin "./tailwind_heroicons.js";
...
```
To see the latest changes for 3.0.0, go to:
[https://github.com/petalframework/petal_pro/tree/v3.0.0/assets/cssgithub.com](https://github.com/petalframework/petal_pro/tree/v3.0.0/assets/css)
The following files have changed:
- `app.css`
- `combo-box.css`
- `editorjs.css`
The following files have been added:
- `colors.css`
- `animations.css`
- `tailwind_heroicons.js`
Finally, `tailwind.config.js` has been removed.
Now you should be able to follow the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide) to update the rest of your project. Running `mix format` should automatically arrange tailwind classes in your project.
### 2.0 -> 2.2
With Petal Components 2.0.6, the `heroicons` dependency has been replaced with a CSS-based approach. This is based on what has been implemented in the Phoenix repo - the reasoning is explained in this [issue](https://github.com/phoenixframework/phoenix/issues/5622). To upgrade an existing Petal Pro project, add the dependency in `mix.exs`:
mix.exs
```diff
def deps do
[
+ {:heroicons,
+ github: "tailwindlabs/heroicons",
+ tag: "v2.1.5",
+ app: false,
+ compile: false,
+ sparse: "optimized"},
]
end
```
This will download a copy of the source SVG files directly from GitHub (into `deps/heroicons/optimized`). Then the following code will process these files to create custom CSS classes:
assets/tailwind.config.js
```diff
const colors = require("tailwindcss/colors");
const plugin = require("tailwindcss/plugin");
+ const fs = require("fs");
+ const path = require("path");
module.exports = {
plugins: [
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
],
};
```
### 1.8 -> 2.0
Starting with 2.0, the Petal Framework has returned to Petal Pro. There is a discussion with regards to this change [in the PR](https://github.com/petalframework/petal_pro/pull/229). The Petal Framework package will be supported in the long run - so there is no need to upgrade immediately. However, over time new features will be added to Petal Pro, rather than the Petal Framework package. If you upgrade to 2.0, you will no longer need to provide your Petal Framework license key. If you have generated a `Dockerfile` for deployment, then you can remove the following section:
Dockerfile
```diff
# Remove the Petal repo:
- RUN --mount=type=secret,id=PETAL_LICENSE_KEY \
- mix hex.repo add petal https://petal.build/repo \
- --fetch-public-key "SHA256:6Ff7LeQCh4464psGV3w4a8WxReEwRl+xWmgtuHdHsjs" \
- --auth-key $(cat /run/secrets/PETAL_LICENSE_KEY)
```
In addition, the `npm` recipe has been merged. Petal Pro is still using `esbuild`, however, javascript libraries are now downloaded as an npm package (instead of depending on a CDN).
You may need to add `nodejs` and `npm` to your `Dockerfile`:
Dockerfile
```diff
# install build dependencies
+ RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
+ && apt-get clean && rm -f /var/lib/apt/lists/*_*
```
In the past `mix assets.setup` would install Tailwind CSS and esbuild. Tailwind is now installed with `mix setup`, esbuild is installed via npm with `mix assets.deploy`. You'll need to remove:
Dockerfile
```diff
- RUN mix assets.setup
```
Finally, `lib/petal_pro_web/petal_framework_translations.ex` is no longer required. Instead, components like the `DataTable` can use [Gettext](https://hexdocs.pm/gettext/Gettext.html) directly.
### 1.6 -> 1.7
Starting in this release, we now use [Styler](https://github.com/adobe/elixir-styler) to format our codebase. This means there will be a large amount of changes. To minimise merge or rebase c onflicts when upgrading, we recommend you swap out Recode with Styler first:
mix.exs
```diff
- {:recode, "~> 0.6", only: [:dev, :test]},
+ {:styler, "~> 0.11", only: [:dev, :test], runtime: false},
```
Then, replace the content of `.credo.exs` with the one in this repo.
Finally, run the formatter for Styler to take care of things:
Another change we recommend you make before upgrading, especially if you've made changes to your landing page, is to rename `PetalProWeb.Components.LandingPage` to `PetalProWeb.LandingPageComponents`, make sure you also rename the file from `landing_page.ex` to `landing_page_components.ex`.
Given the amount of changes in this release, we recommend you backup your branch, then use `merge` instead of `rebase` to bring your branch up to date.
### 1.5.2
When upgrading to this version, you will need to make your local hex aware of our hex repo. Please follow the instructions [here](https://petal.build/components/install-petal-framework) (it involves using a license key unique to you).
### Updating Petal Framework
Updating the Petal Framework package is as simple as updating any dependency - simply update the version in `mix.exs`.
### Updating the boilerplate code
Here is some discussion amongst members in a [Github issue](https://github.com/petalframework/petal_pro/issues/14).
Firstly, I'd use `{rename_project, "~ 0.1.0"}` to rename your project back to `PetalPro` if you renamed it to something else.
Personally, I'd use option 3.
#### Option 1 - Copy your files into a fresh project
*Manual and time-consuming, but surgical.*
If you haven't changed too many existing files and added mostly new ones, you could start a fresh project with the latest Petal Pro and migrate over the work you've done from the previous project. This is likely the easiest route. If you want to keep your git history, then you could try copying the `.git` folder from the previous project into the new one.
#### Option 2 - Using git branches
With this option, you maintain a "pure" Petal branch in your repository with no other modifications. Then, you merge this branch into your main app when you upgrade.
Follow the instructions [here](https://docs.saaspegasus.com/upgrading.html#using-branches-recommended).
#### Option 3 - Rebasing
Keep a branch in sync with the PetalPro repo, and when it updates, you rebase (replay) your commits on top of it.
#### Option 4 - Attempt a git merge
**Option 5 - rsync**
- rsync -r --delete --exclude=.git ~/code/petal_pro-1.3.0/ ~/code/your-app
- git checkout HEAD path/to/files/rsync/deleted/but/shouldnt/have (I used --delete with rsync so I'd know about files that were removed, for example, I think some of the org stuff was moved, and I don't want cruft)
- git add -p
- Use rename_phoenix_project.sh to rename back to your app name
#### Option 6 - Use a diffing tool like Araxis merge
Comment from a Pro user:
The easiest way for me is to create a fresh project from the updated petal_pro, rename it to the same name as your existing project, and then run a diff tool like Araxis merge against the root directories. As long as you have mostly added your own files and not modified the template files much it doesn't take too long. You can either merge the template changes into your existing project (better for git history) or merge your customizations into the fresh project.
---
# Recipes
# Source: https://petal.build/petal-pro/recipes
# Recipes
Recipes are Claude Code slash commands that automate common Petal Pro customizations. Instead of manually editing a dozen files and hoping you didn't miss one, you run a slash command and Claude Code does it for you — reading the codebase, making the changes, and running tests.
Open Claude Code in your project directory and type the slash command to run any recipe.
---
## Feature Removal
Strip out built-in features you don't need. These are destructive — commit first.
| Command | Description | When to use |
|---------|-------------|-------------|
| `/recipes:remove-billing` | Completely removes Stripe billing, subscriptions, and all payment-related code | Free apps, externally-billed apps, or anything that doesn't need in-app payments |
| `/recipes:remove-organisations` | Strips multi-tenancy — orgs, memberships, invitations, org-scoped routes | Single-user apps that don't need teams or workspaces |
| `/recipes:remove-2fa` | Removes TOTP two-factor authentication | Apps with simpler auth requirements |
| `/recipes:remove-oauth` | Removes Google and GitHub OAuth sign-in | Email/password-only apps |
| `/recipes:remove-passwordless-auth` | Removes PIN-based passwordless sign-in | Apps that don't need this auth method |
| `/recipes:remove-notifications` | Removes the in-app notification system, bell icon, and notification preferences | Apps that don't need in-app notifications |
| `/recipes:remove-blog-posts` | Removes the built-in blog/posts system | Apps using an external CMS or no blog at all |
| `/recipes:remove-changelog` | Removes the changelog/release notes feature | Apps that don't need a public changelog |
| `/recipes:remove-api` | Removes the REST API layer and OpenAPI/Swagger docs | LiveView-only apps that don't need a programmatic API |
| `/recipes:remove-admin-chat` | Removes the AI-powered admin chat feature | Apps that don't want AI in the admin panel |
| `/recipes:remove-i18n` | Strips out internationalization — Gettext wrappers, locale detection, translation files | English-only apps |
---
## Architecture Changes
Restructure how the app is organized or how billing and tenancy work.
| Command | Description | When to use |
|---------|-------------|-------------|
| `/recipes:switch-to-single-tenant` | Limits each user to exactly one org (workspace = account, like Notion or Linear) | When you want team features but not multi-org complexity |
| `/recipes:switch-to-user-billing` | Changes billing from org-level to user-level | When individuals should be billed, not teams |
| `/recipes:windows-password-hashing` | Replaces `bcrypt_elixir` with `pbkdf2_elixir` (pure Elixir, no C compiler) | Deploying on Windows or CI environments without native compilation |
---
## Auth Additions
Add authentication methods on top of what ships by default.
| Command | Description | When to use |
|---------|-------------|-------------|
| `/recipes:add-magic-link-auth` | Adds email magic link sign-in (clickable email → logged in, no password) | When you want frictionless auth without PIN codes |
---
## Integrations
Add third-party services and infrastructure.
| Command | Description | When to use |
|---------|-------------|-------------|
| `/recipes:add-s3-uploads` | Configures direct browser-to-S3 uploads via presigned URLs | When you need file uploads that bypass your server |
| `/recipes:cloudinary-uploads` | Adds direct Cloudinary uploads via signed upload parameters | When you want Cloudinary's image transformation and CDN |
| `/recipes:add-custom-domains` | Allows orgs to use their own custom domains (white-label) | Multi-tenant apps that need per-org custom domains |
| `/recipes:google-maps` | Adds a Google Maps component with markers via a LiveView hook | When you need interactive maps |
---
## Feature Additions
Add new capabilities to the app.
| Command | Description | When to use |
|---------|-------------|-------------|
| `/recipes:add-lifetime-deal` | Adds a one-time payment plan alongside subscriptions | AppSumo deals, early-bird pricing, or indie SaaS one-time offers |
| `/recipes:add-waitlist` | Adds a pre-launch waitlist/early access signup page | Before your product launches publicly |
| `/recipes:first-name-last-name` | Splits the single `name` field into `first_name` and `last_name` across the entire codebase | When you need separate name fields in forms, API, OAuth, etc. |
---
## Developer Workflow Commands
These aren't recipes — they're general-purpose commands for working with the codebase day-to-day. Run them from Claude Code in your project directory.
| Command | Description |
|---------|-------------|
| `/plan` | Interview-driven feature planning — asks clarifying questions, generates a structured task file |
| `/task` | Generates a detailed implementation plan with actionable steps and checkboxes |
| `/do_task` | Executes an implementation task from a task file, step by step |
| `/review` | Reviews your git branch for bugs, potential issues, and missing test coverage |
| `/git_commit` | Smart git commit with conventional messages, auto-splits unrelated changes |
| `/push` | Runs CI checks, commits, then pushes to GitHub |
| `/changelog` | Analyzes your git branch and updates CHANGELOG.md |
| `/clean_code` | Fixes compile warnings and failing tests |
| `/database_scan` | Scans database structure and suggests improvements |
| `/regenerate_architecture_doc` | Scans the codebase and regenerates ARCHITECTURE.md |
| `/test_your_work` | QA verification of your changes via browser or IEx |
---
## How to Run a Recipe
1. Install [Claude Code](https://claude.ai/claude-code) and open it in your Petal Pro project directory
2. Type the slash command — e.g. `/recipes:remove-billing`
3. Claude Code reads your codebase, makes all the necessary changes, and runs tests
All recipe files live in `.claude/commands/recipes/` in your project. You can read them to understand exactly what each recipe does before running it.
---
# Installation
# Source: https://petal.build/petal-pro/fundamentals/installation
# Installation
### Prerequisites
You will need:
- [a paid subscription](https://petal.build/pro) to Petal Pro (or be part of a team with a paid subscription)
- this guide is written from the point of view of a Mac. However, it shouldn't be hard to find the equivalents for Linux or Windows
### Quick install instructions
If you just want to see something running without configuring anything:
1. Download the [latest release](https://petal.build/downloads)
2. Run `mix setup`
3. Run `mix phx.server`
You can now sign in as admin:
- Email: `[[emailprotected]](/cdn-cgi/l/email-protection)`
- Password: `password`
Or a normal user:
- Email: `[[emailprotected]](/cdn-cgi/l/email-protection)`
- Password: `password`
### Comprehensive install instructions
#### 1. Download and unzip a release
We recommend downloading the latest version from a proper "Release" (either from [Github](https://github.com/petalframework/petal_pro/releases) or via a Project).
To download via a project you first need to go to the [projects page](https://petal.build/pro/projects) and create one. Then you can download the zip file from your project page.
Done!
If you're a Github member it is possible to clone from the `main` branch, but we don't recommend it because there is a higher chance of bugs. Before each release, we do comprehensive testing and ensure the docs are up to date.
#### 2. Install Elixir & Erlang with asdf
If you already have Elixir and Erlang installed you can skip to the next section.
We recommend using [asdf](https://asdf-vm.com/) to manage your Elixir and Erlang versions. It allows you to have multiple versions on your computer.
For Macs using Homebrew:
```shell
brew install coreutils curl git gnupg gnupg2
brew install asdf
# Add executable to your terminal setup file.
# Bash users:
echo -e "\n. $(brew --prefix asdf)/libexec/asdf.sh" >> ~/.bash_profile
# zsh users
echo -e "\n. $(brew --prefix asdf)/libexec/asdf.sh" >> ${ZDOTDIR:-~}/.zshrc
# Reload your terminal
asdf plugin add erlang
asdf plugin add elixir
# Navigate to the root of your Petal Pro folder
# Check the file .tool-versions - these are the version we will use.
asdf install
```
#### 3. Rename your project (not recommended if you want updates)
⚠️ Warning: if you plan to merge in future Petal Pro commits (eg. if you're waiting for an upcoming feature), then perhaps hold off renaming as it makes merging harder.
Your app module is currently called `PetalPro`. There is a hex package to help you rename:
```elixir
# mix.exs
{:rename_project, "~> 0.1.0", only: :dev, runtime: false}
```
This package enables you to use this command:
```shell
mix rename PetalPro YourNewName
```
#### 4. Run the setup command
In your terminal, run the command:
```shell
mix setup
```
This will fetch dependencies, create and migrate your database, and run the seeds script (creates and admin user and some dummy users).
#### 5. Run the server
```shell
mix phx.server
# Or, if you want IEX running at the same time:
iex -S mix phx.server
```
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
You can now sign in as admin:
- Email: `[[emailprotected]](/cdn-cgi/l/email-protection)`
- Password: `password`
Or a normal user:
- Email: `[[emailprotected]](/cdn-cgi/l/email-protection)`
- Password: `password`
#### 7. Add in your branding and details (can be done later)
We have added comments throughout the codebase providing instructions on how to add in your app details and branding. Simply do a global find in your code editor for the text "SETUP_TODO" and follow the instructions.
#### 8. Look over our recipes
We have some recipes to cover common use cases that aren't part of the normal setup. For example, UUID's instead of ordinary IDs. You can git cherry pick them into your codebase. Check them out [here](/petal-pro/recipes).
### Other tools we use to help develop web apps
- [iTerm2](https://www.iterm2.com/) to replace Mac terminal
- [Fig](https://fig.io/) to enhance your terminal
- [ZSH Prezto](https://github.com/sorin-ionescu/prezto) to replace BASH with zsh within the terminal
- Database GUI - pick one based on your budget:
- [TablePlus](https://tableplus.com/) (paid) - though it's included with a [SetApp](https://setapp.com/) subscription
- [Beekeeper](https://www.beekeeperstudio.io/) (free)
- [SourceTree](https://www.sourcetreeapp.com/) to help use git
- [Dash](https://kapeli.com/dash) for near instantaneous documentation. Useful when you keep looking up Elixir functions like `String.match?`.
- [Linear](https://linear.app/) for issue tracking
- [Figma](https://figma.com) for design
- [Raycast](https://www.raycast.com/) to replace spotlight
### Contributing
Petal Pro is a paid product but we welcome PR's if you find small bugs / typos / improvements. Let us know if you want to contribute in a more significant way and we can offer unlimited membership in return.
---
# Folder Structure
# Source: https://petal.build/petal-pro/fundamentals/folder-structure
# Folder structure
We generally adhere to the folder structure provided by Phoenix. That is, using contexts to group related functionality.
### Adding new components
Note that a lot of the components used in Petal Pro are defined in Petal Pro itself. You can read more [here](/petal-pro/fundamentals/components).
When creating components it can be confusing to know where to define them. We suggest first working out if they are "simple" components (just one function), or complex components (has more than one function - other functions could be helper functions for classes).
**Simple components** can go in `core_components.ex`, which is kind of a big group of common components.
**Complex components** could go in its own file in `lib/your_app_web/components`.
eg. a calendar component could be `lib/your_app_web/components/calendar.ex`.
You can then either import or alias it in your `your_app_web.ex` file (do a find for `use PetalFramework` and do it under that), or just import / alias it in individual files that use it.
### Routes
We have split up the router into:
- `router.ex` - the core router - you will generally just add routes in here
- `dev_routes.ex` - routes purely for development
- `auth_routes.ex` - routes purely for authentication
- `admin_routes.ex` - routes purely for the admin role
- `subscription_routes.ex` - routes purely for billing/subscriptions
It can be tempting to create more route files like `org_routes`, but we found this doesn't work well with live navigation. Every route scoped in a `live_session` call will be part of the same "live navigation". This means navigating between these routes won't cause a page refresh.
We recommend adding all your app-related routes in `router.ex` rather than splitting them into separate files. If you split the org routes into a different file, then when a user navigates from a non-org route to an org route, the page will refresh because it's not in the same `live_session`.
### Notifications
We have a folder called "notifications" that will house all notification types you want to send to users. For now, it only has `email.ex`, but if you want to support SMS, you could put the module that generates SMS's in this folder.
---
# Users & Authentication
# Source: https://petal.build/petal-pro/fundamentals/users-and-authentication
# Users & Authentication
## Users
The `user.ex` schema looks like this:
user.ex
```elixir
defmodulePetalPro.Accounts.Userdo
...
schema "users"do
field :name,:string
field :email,:string
field :password,:string,virtual:true,redact:true
field :hashed_password,:string,redact:true
field :confirmed_at,:naive_datetime
field :is_admin,:boolean,default:false
field :avatar,:string
field :last_signed_in_ip,:string
field :last_signed_in_datetime,:utc_datetime
field :is_subscribed_to_marketing_notifications,:boolean,default:true
field :is_suspended,:boolean,default:false
field :is_deleted,:boolean,default:false
field :is_onboarded,:boolean,default:false
field :current_org,:map,virtual:true
many_to_many :orgs,Org,join_through:"orgs_memberships",unique:true
timestamps()
end
...
end
```
Users have some extra fields not included by `phx.gen.auth`:
| Field | Type | Description |
| --- | --- | --- |
| `name` | `:string` | A users full name |
| `avatar` | `:string` | A URL to the users avatar image |
| `last_signed_in_ip` | `:string` | The IP address of the last login by this user. |
| `is_subscribed_to_marketing_notifications` | `:boolean` | Track whether a user wants to receive marketing emails or not. |
| `is_admin` | `:boolean` | Admins get access to a special dashboard where they can modify users, see logs, etc. |
| `is_suspended` | `:boolean` | An admin can suspend a user, preventing them from logging in. |
| `is_deleted` | `:boolean` | Allows for soft deletion of users |
| `is_onboarded` | `:boolean` | Track whether or not a user has seen an onboarding screen after registering. |
## Authentication
We used [phx.gen.auth](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html) (email/password) and modified the templates to use Tailwind and Petal Components.
### Setting and accessing the current user
#### Controller actions
For controller actions we use the plug provided by `mix phx.gen.auth` to set `conn.assigns.current_user` .
user_auth.ex
```elixir
defmodule PetalProWeb.UserAuth do
...
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
end
...
end
```
You can see the `:fetch_current_user` plug used in the `:browser` pipeline in the router.
router.ex
```elixir
defmodule PetalProWeb.Router do
...
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {PetalProWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
plug PetalProWeb.SetLocalePlug, gettext: PetalProWeb.Gettext
end
...
end
```
If you want to enforce the user then you can use the `:require_authenticated_user` plug.
router.ex
```elixir
scope "/", PetalProWeb do
pipe_through [:browser, :require_authenticated_user]
# Routes that require a logged in user go here
end
```
#### Live views
We can't rely on our plugs in live views, since live views connect over web sockets and avoid the traditional request/response lifecycle. However, a live view will have access to the session, which contains the user token set upon login. Hence, in the live view mount we can use the token to find the `user_token` set in our database for that users session, and from there obtain the logged in user.
Instead of doing this on every live view `mount` function, we can extract this out into an [on_mount](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#on_mount/1) function and then apply it in the router, like a mini pipeline.
user_on_mount_hooks.ex
```elixir
defmodule PetalProWeb.UserOnMountHooks do
...
def on_mount(:require_authenticated_user, _params, session, socket) do
socket = maybe_assign_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
{:halt, redirect(socket, to: Routes.user_session_path(socket, :new))}
end
end
defp maybe_assign_user(socket, session) do
assign_new(socket, :current_user, fn ->
get_user(session["user_token"])
end)
end
...
end
```
router.ex
```elixir
defmodule PetalProWeb.Router do
...
scope "/", PetalProWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :protected, on_mount: {PetalProWeb.UserOnMountHooks, :require_authenticated_user} do
# Live routes that require a logged in user go here
end
end
...
end
```
## Social login providers
Petal Pro uses [Ueberauth](https://github.com/ueberauth/ueberauth) to handle social providers. Ueberauth simplifies the oauth process and have a large number of providers supported. We have set up a couple of providers to get you started:
- Google
- Github
#### How does it work?
A user is redirected to a provider. The URL for this redirect is generated by the Ueberauth library:
```elixir
Routes.user_ueberauth_path(@conn, :request, "google")
Routes.user_ueberauth_path(@conn, :request, "github")
```
When you setup either "Google" or "Github", buttons will appear in the sign in and register pages that go to those URL's above.
Once a user has successfully signed in to the provider, they are redirected back to your web application. You can see where they end up by looking in the router:
```elixir
scope "/", PetalProWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/auth/:provider", UserUeberauthController, :request
get "/auth/:provider/callback", UserUeberauthController, :callback
end
```
In the file `user_ueberauth_controller.ex` there are callback functions - one for each provider. In these callback functions we take the user info provided by the provider and use that to sign in a user (registering them if they aren't already):
user_ueberauth_controller.ex
```elixir
def callback(%{assigns: %{ueberauth_auth: %{info: user_info}}} = conn, %{"provider" => "google"}) do
user_params = %{
email: user_info.email,
name: combine_first_and_last_name(user_info),
password: random_password(),
avatar: user_info.image
}
case Accounts.fetch_or_create_user(user_params) do
{:ok, user} ->
UserAuth.log_in_user(conn, user)
{:error, _} ->
conn
|> put_flash(:error, "Authentication failed")
|> redirect(to: "/")
end
end
```
#### How does this fit in with the existing `user` schema?
The user table doesn't change. Everything revolves around a user's email address. A user might register with a password and some time later login via Google. As long as the email used for Google sign in is the same as the registered email, then the login will work. The same goes for Github and Twitter.
If a user doesn't exist and they login via a provider, then they will be registered under the email address given by the provider. Since the user table has a not-null constraint on the `hashed_password` column, a password will be randomly generated. This way we don't modify the original table structure of the `mix phx.gen.auth` generator.
If a user logs in with a provider and then wants to change their (randomly generated) password they will have to click "Forgot my password".
#### **What if the user forgets what social login they used?**
Under this implementation there is nothing you can do apart from tell the user to try each provider and see which works. You could add a new field to the user schema (`user.provider`) if you like.
### Setup Google sign in
1. Create a [Google Project](https://console.cloud.google.com/projectcreate)
2. Create [Oauth 2.0 Client ID](https://console.cloud.google.com/apis/credentials) (you’ll likely have to create an OAuth consent screen before you can create the OAuth Client ID)
- The user type you’ll require will be External
- Go to "Create credentials" -> "OAuth client ID" and fill in the details - make sure of the following:
- **Authorized JavaScript origins** - `http://localhost:4000`
- **Authorized redirect URIs** - `http://localhost:4000/auth/google/callback`
3. Add your client ID + client secret to your `.envrc` file:
1. export GOOGLE_OAUTH_CLIENT_ID=""
2. export GOOGLE_OAUTH_SECRET=""
4. Stop your server and make sure you enter `direnv allow`
### Setup Github sign in
1. Create an OAuth App - it can be either under your personal account or an organization:
1. Personal account:
1. Click your avatar -> Settings
2. Developer settings (down the bottom)
2. Organization account:
1. Go to your organization's profile
3. Developer settings (down the bottom)
3. Fill in the details - the only thing that matters is "Authorization callback URL", which should be `http://localhost:4000/auth/github`
5. Paste client ID and secret into your `.envrc` file:
1. `export GITHUB_OAUTH_CLIENT_ID=""`
2. `export GITHUB_OAUTH_SECRET=""`
### Adding more providers
See a list of Ueberauth strategies [here](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies). You can copy how we've done it for the previous 3 providers. Check out the file `user_ueberauth_controller.ex` for how to deal with the callbacks. Your main job is taking the data given by the provider and using it to register a user.
## Passwordless
If enabled, users can **both** register and login without a password (it's enabled by default).
Passwordless sign in was added in v1.2.0. You can toggle it on and off in `config.exs`. When it's enabled, a "Continue with passwordless" button will show up on the sign-in and register pages.
#### How passwordless works
Enabling passwordless adds a new option on the sign-in and register screens. Passwordless allows users to either register or log in with their email address. They get sent a code and use the code as a temporary password. The code expires in a short timeframe and gets deleted if the user enters the wrong code for more than a couple of attempts.
You can think of passwordless as another social login. However, instead of a company like Github verifying the user's credentials, the user's email service verifies them instead. The main difference is that you don't get other information like a user's name and avatar with passwordless - only the email address.
This means users can register and access your web app with only an email. For Petal Pro this is okay, as during onboarding we ask the user to add their name. We also generate a random password for them (every user requires a password as a database rule set by phx.gen.auth). A user therefore can click "Reset password" if they like and choose to use password-based login in the future.
The passwordless flow is handled in `PetalProWeb.PasswordlessLive`.
#### Passwordless process:
2. Find or create a user using the email (new users are given a random password, which they can reset at any time), and set the user to `assigns.auth_user`
3. A `user_token` is generated with the encrypted pin code in it
4. Push patch to `/passwordless/sign-in-code/:hashed_user_id` (user id is obfuscated in a hashed format using the HashId lib)
5. The user enters the code sent to their email (gets a limited number of attempts before the `user_token` is deleted
6. If correct - generates a sign-in token for the user - however live views can't insert cookies, so we need to make a trip to the server
7. Place the sign-in token in a hidden field in a form (encoded it as base64)
8. Submit the form using [phx-trigger-action](https://hexdocs.pm/phoenix_live_view/form-bindings.html#submitting-the-form-action-over-http)
9. This POSTs it to `PetalProWeb.UserSessionController.create_from_token/2`
10. `create_from_token/2` will use the token to log the user in (set the appropriate cookie)
## Two-factor authentication (2FA) with TOTP
2FA boosts the security of your web application by allowing users to opt into a two-factored sign-in procedure (their ordinary sign-in plus entering a one-time password).
Users can go to their settings and enable 2FA by syncing their account to a tool that implements the Time-based One-time Password (TOTP) algorithm (the main one being Google Authenticator). The settings page presents a QR code that can be scanned with Google Authenticator or a compatible app.
Once set up the user will also be provided with some backup codes (in case they lose their phone). Each backup code can be used once as a fallback if the authenticator app is unavailable.
Now, when signing in a user will be forced to enter a TOTP from their authenticator app. The sign-in flow presents a code entry screen after the initial password step.
### How is TOTP implemented?
The schema looks like this:
The key field is the `secret`. This is what is passed to the authenticator app and allows the web application to work out which codes are correct.
### How to turn off 2FA
The simplest way is to remove access to the 2FA settings. This will prevent users from setting it up. In future it will also be easy enough to turn it back on if you want.
1. Remove the routes:
2. Remove the menu item in the user settings layout:
---
# Passkeys
# Source: https://petal.build/petal-pro/fundamentals/passkeys
# Passkeys
Passkeys are a phishing-resistant alternative to passwords. Instead of typing a password, users authenticate with whatever their device already uses - Face ID, Touch ID, Windows Hello, or a hardware security key like a YubiKey. There's no password to steal, no password to forget, and no phishing page that can intercept credentials.
Petal Pro ships with passkey support out of the box. It's disabled by default so you opt in when you're ready.
## Enabling passkeys
Flip the feature flag in your config:
config/config.exs
```elixir
config :petal_pro, :passkeys_enabled, true
```
That's it. The sign-in page will now show a "Sign in with passkey" button, and the security settings page will show a passkey management section where users can register their devices.
To check the flag at runtime:
```elixir
PetalPro.passkeys_enabled?()
```
## Custom domain config
By default, passkeys are scoped to the host of your endpoint URL. This is the `rp_id` (relying party ID) in WebAuthn terms - it determines which domains a passkey is valid for.
Most of the time you don't need to touch this. If you're running on a subdomain and want passkeys to work across the whole domain (e.g., register on `app.example.com` and sign in on `example.com`), set these explicitly:
config/runtime.exs
```elixir
config :petal_pro,
passkeys_rp_id: "example.com",
passkeys_origin: "https://app.example.com"
```
`passkeys_rp_id` is the domain passkeys are scoped to. `passkeys_origin` is the full origin your app is served from. In production, set these via environment variables if your domain isn't hardcoded.
⚠️ Warning: A passkey registered on one origin won't work on another. If you change passkeys_rp_id in production, existing passkeys will stop working. Get this right before users start registering.
## How it works for users
### Registering a passkey
Users manage their passkeys from the security settings page at `/app/users/security`. They can:
- Register up to 5 passkeys
- Give each passkey a name (e.g. "MacBook Touch ID", "iPhone Face ID")
- Delete passkeys they no longer need
When registering, the browser prompts for the local authenticator - Face ID, fingerprint, PIN, or a hardware key. The private key never leaves the device. Petal Pro stores a public key credential in the `user_passkeys` table.

Once registered, the security settings page shows all your passkeys with the option to toggle 2FA mode or remove individual keys.

### Signing in
When passkeys are enabled, the sign-in page shows a "Sign in with passkey" button. The user clicks it, the browser presents available passkeys for the site, and they authenticate with their device. No password needed.

The relevant routes:
```
GET /users/passkey-verify # shows the passkey challenge prompt
POST /users/passkey-verify # completes the authentication
```
## Primary auth vs 2FA mode
Passkeys work in two modes in Petal Pro:
**Primary auth (passwordless)** - The user clicks "Sign in with passkey" on the login page. They never touch a password field. This works for any user who has registered at least one passkey.
**Second factor (2FA)** - After signing in with a password, users with `passkey_2fa_preferred: true` get redirected to `/users/passkey-verify` to confirm with their passkey instead of a TOTP code. This gives you the convenience of passkeys without fully replacing password login.
The `passkey_2fa_preferred` flag lives on the `User` schema. Users can toggle it from their security settings once they have a passkey registered. You can check it programmatically:
```elixir
PetalPro.Accounts.passkey_2fa_preferred?(user)
```
ℹ️ Note: Passkeys and TOTP are independent. A user can have both set up. If passkey_2fa_preferred is true, the passkey prompt takes precedence over the TOTP prompt after password sign-in.
## Removing passkeys entirely
If you don't want passkeys in your app at all, here's what to clean up:
1. **Config** - Remove or leave `passkeys_enabled: false` in `config/config.exs`. This is enough to hide all the UI.
2. **Schema** - If you want to remove the database fields too, drop `user_passkeys` table and remove `passkey_2fa_preferred` from the `users` table. You'll need to write a migration for that.
3. **Routes** - Remove the passkey routes from `router.ex`. Search for `/users/passkey-verify` and the `EditPasskeysLive` route.
4. **UI** - The "Sign in with passkey" button on the login page and the passkey section in security settings are both gated on `passkeys_enabled?()`, so they disappear automatically when the flag is off. If you want to remove the dead code entirely, search for `passkeys_enabled?` and `EditPasskeysLive` across the codebase.
5. **Dependencies** - Passkeys are powered by the `wax` Hex package. If you're stripping the feature entirely, remove it from `mix.exs`.
Most projects won't need to do any of this - disabling the flag is enough to ship without passkeys, and you can always turn it back on later.
---
# Organizations & Multitenancy
# Source: https://petal.build/petal-pro/fundamentals/organizations-and-multitenancy
# Organizations & Multitenancy
We have provided a basic structure for organizations, which are basically groups of people. Often user want to create some kind of organization (eg. a company), and then invite their teammates into it. This is a step towards multi-tenancy, with an organization representing a tenant.
If you were introducing a payment system, it would likely be associated with the org (we will implement this in v1.3.0.
## Database structure
The data model is organized around three main tables: `orgs`, `memberships`, and `invitations`. Users belong to orgs through memberships, and each membership carries a role.
## Org routes
The following routes are provided:
```elixir
live "/org/new", NewOrgLive
live "/org/:org_slug", OrgDashboardLive
live "/org/:org_slug/edit", EditOrgLive
live "/org/:org_slug/team", OrgTeamLive, :index
live "/org/:org_slug/team/invite", OrgTeamLive, :invite
live "/org/:org_slug/team/memberships/:id/edit", OrgTeamLive, :edit_membership
```
This provides some basic functionality to get you started:
- Any user can CRUD an org
- Basic roles (`admin` & `member`)
- Members can create invitations for new members
- Invitations turn into memberships upon acceptance
- Admins can delete memberships
## Org plugs
We have some plugs to help with assigning org related data
### assign_org_data
This will assign to the conn:
| Assigns | When | Description |
| --- | --- | --- |
| `@orgs` | Always | A list of orgs the `current_user` is in |
| `@current_membership` | Only if `:org_slug` is in the route | The current user's membership with the current org (using the provided slug). |
| `@current_org` | Only if `:org_slug` is in the route | The current org (using the provided slug) |
### require_org_member
This makes sure the current_user is a member of the current org (determined by the URL param `:org_slug`). The path must have `:org_slug` in it.
### require_org_admin
Same as `require_org_member` above but the user must have a role of admin on their membership for the org.
## Org on_mount hooks
You can run `on_mount` hooks in your `live_session` calls. For example:
```elixir
live_session :require_confirmed_user,
on_mount: [
{PetalProWeb.UserOnMountHooks, :require_confirmed_user},
{PetalProWeb.OrgOnMountHooks, :assign_org_data}
] do
live "/org/new", NewOrgLive
# page_builder:live:protected
end
```
We have hooks matching the [plugs](/petal-pro/fundamentals/organizations-and-multitenancy#org-plugs) above:
- [assign_org_data](/petal-pro/fundamentals/organizations-and-multitenancy#assign_org_data)
- [require_org_member](/petal-pro/fundamentals/organizations-and-multitenancy#require_org_member)
- [require_org_admin](/petal-pro/fundamentals/organizations-and-multitenancy#require_org_admin)
## Multi-tenancy
Currently you can only see your current org if you are in a route scoped to that org (`/:org_slug/page`), with the org slug representing the tenant (org).
There are several options when it comes to identifying the current org (listed below). Please let us know if you want more options supported. We have a feedback form [here](https://petal.build/feedback).
#### 1. Params (supported)
Load the org from a body or query param (how it's currently done).
```elixir
/org/my-org/path_x
/org/my-org/path_y
/org/my-org/path_z/:some_param
```
#### 2. Session (not yet supported)
Set the current org as a cookie and fetch it in the plug from the cookie. Then you don't have to scope the route:
```elixir
/path_x
/path_y
/path_z/:some_param
```
You will need to create a plug that sets the current org - you could use `set_locale_plug.ex` as inspiration.
#### 3. Subdomain (not yet supported)
Set the tenant slug as a subdomain. Similar to "params" but means you don't have to scope all of your routes.
```elixir
my-org.yourdomain.com/path_x
my-org.yourdomain.com/path_y
my-org.yourdomain.com/path_z/:some_param
```
You would need to create a plug to do this.
### How to prevent data leakage - true multi-tenancy
Currently, all your orgs (tenants) data is in one database. It is up to you to scope all of your Ecto queries to ensure the data shown on the page belongs to the right org.
A potentially safer option is to make a new [Postgres Schema](https://www.postgresql.org/docs/current/ddl-schemas.html) per tenant with the help of [Triplex](https://github.com/ateliware/triplex). In which case your queries will be like this:
```elixir
Repo.all(User, prefix: Triplex.to_prefix("my_tenant"))
Repo.get!(User, 123, prefix: Triplex.to_prefix("my_tenant"))
```
Triplex provides some plugs to help set the org:
```diff
- `Triplex.ParamPlug` - loads the tenant from a body or query param
- `Triplex.SessionPlug` - loads the tenant from a session param
- `Triplex.SubdomainPlug` - loads the tenant from the url subdomain
- `Triplex.EnsurePlug` - ensures the current tenant is loaded and halts if not
```
If you are unsure about multi-tenancy, [this blog post](https://blog.arkency.com/comparison-of-approaches-to-multitenancy-in-rails-apps/) explains the different approaches well.
## Removing organizations
You will have to extract orgs out of your code. This will take approximately 15 minutes. If you have time, consider letting us know at [[emailprotected]](/cdn-cgi/l/email-protection) if you do this so we can get an idea of how many people are doing it (if it's a lot, then we will consider making it easier to delete).
#### Steps to delete
Delete the migrations:
Delete files and folders related to orgs:
Remove references in `user.ex`and `log.ex:`
From here, you can just do a global search for "org" and delete what's left.
- `Accounts.preload_org_data/2`.
- Router org routes.
- `Router.assign_org_data/2` plug.
- Reference in `Logs.build/2`.
- Org related logs in `log.ex` - `@action_options`
- `UserNotifier.deliver_org_invitation/3`
- Delete `org_seeder.ex`
- References in `seeds.exs`
- References in `email_testing_controller.ex`
- References in `email.ex`
- Email template: `org_invitation.html.heex`
- Test folder:
- `org_team_live_test.exs`
- `orgs_fixtures.ex`
- `dashboard_live_test.exs`
---
# Stripe Billing
# Source: https://petal.build/petal-pro/fundamentals/stripe-billing
# Stripe billing
Easily add payment plans to your web app with our Stripe integration (v1.7.0 onwards).
## The Stripe schema
To help understand subscriptions, it's useful to know how Stripe structures its data. A Subscription comprises several SubscriptionItems, which each match to a Price. This allows you a large amount of flexibility in constructing your subscription plans. Think of a plan in terms of a collection of Prices.
Thus, when you want to define plans for your users to buy, you:
- Create a Product in Stripe
- For that product, you create any number of prices - usually a monthly and yearly price
- You copy the Price IDs and add them to your Petal Pro config as `items` (in `config.exs`)
Usually, you will only have one "item" in there. But sometimes you may want to add two - eg. you might have a once-off price as well as a monthly price.
Quantity will also mostly stay the same - 1. If your plans have a number of seats - then `quantitiy` would represent that variable.
The other info in the config is mainly for your front-end UI. The Stripe Prices are the main thing you want to copy across from Stripe.
### How our config is used to create checkout sessions
- Explain what a checkout session is, how our items are passed in as line items and how it generates a hosted checkout page Stripe
- Explain how webhooks work and how we use Oban
These docs are a work in progress. Expect more soon! For now follow the ["Adding a subscription" guide](/petal-pro/guides/adding-a-subscription).
---
# Notifications
# Source: https://petal.build/petal-pro/fundamentals/user-notifications
# 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](/petal-pro/fundamentals/layouts-and-menus#sidebar-layout) and [Stacked Layout](/petal-pro/fundamentals/layouts-and-menus#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">
content
```
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](/petal-pro/fundamentals/user-notifications#the_read_path) matches.
## Consuming Broadcast Messages
The `UserNotificationChannel` is a [Channel](https://hexdocs.pm/phoenix/channels.html) 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](https://hexdocs.pm/phoenix/channels.html#client-libraries) 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
ℹ️ Information: 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).
## 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" />
{@notification.message}
"""
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.
---
# Emails
# Source: https://petal.build/petal-pro/fundamentals/emails
# Emails
## Transactional emails
Open up `email.ex` and you will see a list of functions that generate Swoosh email structs. Eg:
```elixir
def confirm_register_email(email, url) do
base_email()
|> to(email)
|> subject("Confirm instructions")
|> render_body("confirm_register_email.html", %{url: url})
|> premail()
end
```
If I run this function in IEX I can see the Swoosh struct:
A `Swoosh.Email` struct can be delivered to an email address by a Swoosh mailer (see `mailer.ex`). Eg:
```elixir
MyApp.Email.confirm_register_email(user.email, url)
|> MyApp.Mailer.deliver()
```
So `email.ex` creates the Swoosh structs and the functions for actually delivering emails like the previous code example are in `user_notifier.ex`. Think of `email.ex` functions like view templates, and `user_notifier.ex` functions like controller actions.
The steps to creating a new email:
1. Create the function to generate a Swoosh struct in `email.ex`
2. Ensure it looks good by navigating to `http://localhost:4000/dev/emails` and creating a new action in `email_testing_controller.ex` that renders the `html_body` value of the struct
3. Create a new `user_notifier.ex` function for use in your application
To see a more detailed write up on creating an email — see [this section](/petal-pro/guides/creating-a-web-app-from-start-to-finish#reminder-emails) in the "Complete web app" guide.
## Email provider: Resend
Petal Pro v4 uses [Resend](https://resend.com) as the sole transactional email provider via the `Swoosh.Adapters.Resend` adapter. AWS SES has been removed.
### Configuration
Set the `RESEND_API_KEY` environment variable (get it from your Resend dashboard):
```shell
fly secrets set RESEND_API_KEY="re_..."
```
In `runtime.exs` the mailer is configured automatically when the key is present:
```elixir
config :petal_pro, PetalPro.Mailer,
adapter: Swoosh.Adapters.Resend,
api_key: System.fetch_env!("RESEND_API_KEY")
```
No `MAIL_PROVIDER` env var is needed — Resend is the only option.
## Email template components
Petal Pro ships a set of HEEx email layout components you can compose to build consistent, well-styled transactional emails. These live in `lib/petal_pro_web/components/email_components.ex`.
### Configurable section gap
The gap between sections in the email body is configurable via a `gap` assign on the wrapper component:
```heex
<.email_body gap="32px">
<.email_section>First section
<.email_section>Second section
```
### Gray button variant
In addition to the default primary button, a `gray` variant is available for secondary actions (e.g. "Unsubscribe", "View in browser"):
```heex
<.email_button href={@url} variant="gray">
Manage preferences
```
### Callout boxes
Use callout boxes to draw attention to important information — warnings, confirmation codes, or key instructions:
```heex
<.email_callout>
Your confirmation code is 482901. It expires in 15 minutes.
```
Callout boxes render with a subtle background and border, making them stand out from surrounding body text.
## Marketing emails with Resend
Marketing emails are emails you wish to send to your whole subscriber base or some segment of them to push a marketing or product update. They differ from transactional emails in that you don't program them in code — you compose them in the [Resend dashboard](https://resend.com) and hit send.
We use [Resend](https://resend.com) for marketing because it has a clean API, generous free tier, and a good unsubscribe / deliverability story out of the box. Transactional email continues to go through Swoosh + AWS SES — these are two separate concerns.
### How sync works
Contacts (your users) sync to Resend automatically:
- On sign-in, profile update, notification settings change, onboarding completion, and membership expiry, an Oban job (`PetalPro.Workers.ContactSyncWorker`) runs `PetalPro.MarketingEmail.sync_contact/1`
- That call upserts the user as a Resend contact (email, first name, unsubscribed flag) and reconciles their segment membership
- Suspended or deleted users are removed from Resend entirely
To backfill existing users (e.g. after first deploy), run:
```elixir
# In iex
PetalPro.MarketingEmail.sync_all_contacts()
# Or as a Mix task
mix petal_pro.sync_to_resend
```
Each user gets its own Oban job with random 0-600s jitter to stay under Resend's default 2 req/sec rate limit.
### Segments
Resend slices contacts via segments (formerly "audiences"). We use three:
| Env var | Members |
|---|---|
| `RESEND_SEGMENT_ID_ALL_USERS` | All confirmed, non-suspended, non-deleted users with `setting_notification_subscribe_newsletter = true` |
| `RESEND_SEGMENT_ID_MEMBERS` | Active Petal Pro members |
| `RESEND_SEGMENT_ID_EXPIRED_MEMBERS` | Users whose membership has expired and who have no active membership |
A user can be in multiple segments simultaneously (a current member is in both `ALL_USERS` and `MEMBERS`). On every sync we read the contact's current segments from Resend, diff against the desired set, and add/remove explicitly via `POST/DELETE /contacts/:id/segments/:segment_id`.
To add a new segment:
1. Create the segment in the Resend dashboard, capture its ID
2. Add a config key in `config/runtime.exs`
3. Add a clause in `PetalPro.MarketingEmail.desired_segments/1`
### Inbound webhook
Resend sends events to `POST /webhooks/resend` (handled by `PetalProWeb.ResendWebhookController`). Signatures are verified via svix HMAC against `RESEND_WEBHOOK_SECRET`.
| Event | Action |
|---|---|
| `contact.updated` with `unsubscribed: true` | Flips `setting_notification_subscribe_newsletter` to false on the matching user |
| `email.bounced` | Logged to `Logs` for review |
| `email.complained` | Logged to `Logs` for review (spam complaints damage sender reputation) |
### Required env vars
```bash
RESEND_API_KEY=re_...
RESEND_SEGMENT_ID_ALL_USERS=...
RESEND_SEGMENT_ID_MEMBERS=...
RESEND_SEGMENT_ID_EXPIRED_MEMBERS=...
RESEND_WEBHOOK_SECRET=whsec_...
```
---
# Blog & CMS
# Source: https://petal.build/petal-pro/fundamentals/blog-cms
# signal-streamBlog/CMS
Blogging system based on the Content Editor component.
The Content Management System (CMS) provides a basic blogging platform. The CMS provides a set of basic features:
- Minimal data structure - one table for Posts, one table for Files
- Simple publishing process - draft fields are copied over published fields
- Rich content is supported via the Content Editor [component](https://petal.build/components/content-editor) (integrated with [Editor.js)](https://editorjs.io/)
- File browser allows the user to upload and select files
- Admin console to manage blog posts
- Basic UI to show published posts
### Data Entry
To create a blog post, go to the admin console:
```elixir
/admin/posts
```
Here you'll see the list of posts in the admin console. To create a post, click the "New Post" button. A modal will appear where you fill in the fields (only Title is required) and click on "Save and Continue". Here you'll see the main data entry screen for the post, including the cover image area and the content editor below it.
You can click the cover image to bring up the [file browser](/petal-pro/fundamentals/blog-cms#file-browser). The main data entry starts at the section that says, "Start typing...". This part of the document is using the [Content Editor](/petal-pro/fundamentals/blog-cms#content-editor).
ℹ️ Information: While on the edit page, any changes made to the document will be auto-saved!
### Publishing Process
Once your happy with your edits, click on "Save and Finish". This will bring you to a preview of the current draft. To make this post publically available, click on the "Publish" button. A publish modal will appear where you select a "Go Live" date/time and hit the second "Publish" button to complete the process.
ℹ️ Information: The Go Live date/time is based on UTC. Currently there is no support for user/timezone configuration in Petal Pro. If the time of day matters, you'll need to convert it to UTC before filling out this form.
Draft data will be copied over published fields. For more details on what those fields are and what else happens, see the [Data Structure](/petal-pro/fundamentals/blog-cms#data-structure) section. Assuming the post is live, it will be available in the Blog.
### Un-publishing
Once a post has been published, you can bring up the Publish modal again. The "Remove" button will now be enabled for the published post. To un-publish, click the "Remove" button. This won't affect data entry, but it will ensure that the post is no longer publically available via the Blog.
### Content Editor
The Content Editor [component](https://petal.build/components/content-editor) is based on [Editor.js](https://editorjs.io/). You can use it to create paragraphs, lists, tables, insert images and even insert embeds.
pic
Editor.js is a block-style editor that that generates a json document as output. This json is captured and stored in the `Post` data structure (see [Data Structure](/petal-pro/fundamentals/blog-cms#data-structure) below for more details). Rendering is taken care of by the Content Editor [component](https://petal.build/components/content-editor), via the `.content` and `.pretty_content` function components.
### Pre-installed Editor.js Plug-ins
Editor.js has it's own eco-system - functionality is provided via plug-ins. The following plug-ins are installed by default:
- [Header](https://github.com/editor-js/header) (`h1` to `h6`)
- [Quote](https://github.com/editor-js/quote) (similar to a markdown quote)
- [Marker](https://github.com/editor-js/marker) (a tool for highlighting sections of text)
- [InlineCode](https://github.com/editor-js/inline-code) (so you can display inline text as code)
- [Code](https://github.com/editor-js/code) (so you can show a code block)
- [Delimiter](https://github.com/editor-js/delimiter) (break up paragraphs with a line-based delimeter)
- [List](https://github.com/editor-js/list) (ordered and unordered lists)
- [SimpleImage](https://github.com/editor-js/simple-image) (paste in a url to an image and it will render)
- [Table](https://github.com/editor-js/table) (UI for creating a HTML table)
- [Warning](https://github.com/editor-js/warning) (displays an alert, similar to the next section)
- [Embeds](https://github.com/editor-js/embed) (e.g. paste a YouTube link to create an embedded iframe)
ℹ️ Information: The SimpleImage plug-in will only work for urls that are configured in the Content Security Policy. See the [Data Structure](/petal-pro/fundamentals/blog-cms#data-structure) section for more information on the Content Security Policy
Finally, we've created a plug-in that integrates with the file browser, called `PetalImage`
### Adding Plug-ins
There are many core and community-based plug-ins available for Editor.js and there's nothing stopping you from using them! Petal Pro uses `npm` for installation. Documentation for installing plug-ins can be found [here](https://editorjs.io/getting-started/#tools-installation).
The only requirement is that the Content Editor component is updated so that the renderer can output data from the new plug-in. Thankfully, this is extremely easy to do. In fact, here's a tutorial on how you can create your own Editor.js plug-in and adjust the Content Editor:
[Content Editor - adding your own plug-in](/petal-pro/guides/content-editor-adding-your-own-plug-in)
### Data Structure
Here's what a `Post` looks like:
```elixir
schema "posts" do
# Draft fields (used for editing)
field :category, :string
field :title, :string
field :slug, :string
field :cover, :string
field :cover_caption, :string
field :summary, :string
field :content, :string
field :duration, :string
# Published fields
field :published_category, :string
field :published_title, :string
field :published_slug, :string
field :published_cover, :string
field :published_cover_caption, :string
field :published_summary, :string
field :published_content, :string
field :published_duration, :string
# The last time this post was published
field :last_published, :naive_datetime
# Date/time when post is publically available.
# `nil` means "private" or "not published"
field :go_live, :utc_datetime
belongs_to :author, PetalPro.Accounts.User
timestamps(type: :utc_datetime)
end
```
Blog posts belong to a user. However, the default behaviour is that only admins have access to the console.
As a general rule, the draft fields are used in the admin console. The published fields are only used in the public facing Blog.
At the time of saving a draft, if the `:title` has changed, then it is used to generate `:slug`. The `:slug` includes the `:id` as a postfix (base64 encoded). So if the title is, "Hello Fred", then the slug will look similar to `hello-fred-OwAw1rxK`. This way it won't matter if the `:title` changes, pasting an old `:slug` into the browser will still result in loading the correct Blog post.
When publishing a post, the draft fields are copied over the published fields. In addition `:last_published` is set to `DateTime.now_utc` and `:go_live` is based on the user's data entry.
The output of the Content Editor component (i.e. Editor.js json [data structure](https://editorjs.io/base-concepts/#what-is-clean-data)) is stored in the `content` field (and published to the `published_content` field).
The `cover` field is a url to an image (and by extension `published_cover` is too). This can be any url, but you'll need to make sure the domain is configured in the Content Security Policy. To find out how to configure the Content Security Policy (or disable it), check out:
[Deployment](/petal-pro/fundamentals/deployment)
Though the `cover` image can point to any url, in reality the domain will probably be defined by the file browser. See the [File Browser](/petal-pro/fundamentals/blog-cms#file-browser) section (below) for more information.
Here's what the `File` data structure looks like:
```elixir
schema "files" do
field :url, :string
field :name, :string
# Files aren't removed, they're archived
field :archived, :boolean, default: false
belongs_to :author, PetalPro.Accounts.User
timestamps(type: :utc_datetime)
end
```
Again, a file belongs to a user, but by default only admins have access to the console.
A file points to a url and has a name. Files are intended to be single use - you can add a file, name it and then you're done. If you want something different, you add a new file. If you want to replace a file, archive the old file and add a new one. This behaviour is supported by the File Browser. The main benefit of this design is that you can control what you see, but limit the chance of accidentally deleting a file that's in use.
### File Browser
The file browser provides a means to upload and select images. It presents a gallery of previously uploaded files and an option to upload new ones.
ℹ️ Information: NB - the file browser is currently targetted at images (i.e. files that are easy to preview). Other file types (such as pdf documents) could be supported but will require more work to enable. If you're interested in a feature like this, please consider adding it to the Petal Pro [roadmap](https://petal.build/pro/roadmap).
Based on LiveView [Uploads](https://hexdocs.pm/phoenix_live_view/uploads.html) the file browser defaults to storing files on the local server:
```elixir
defmodule PetalProWeb.AdminFilesLive.FormComponent do
@moduledoc false
use PetalProWeb, :live_component
alias PetalPro.Files
alias PetalPro.Files.File
alias PetalProWeb.FileUploadComponents
@upload_provider PetalPro.FileUploads.Local
# @upload_provider PetalPro.FileUploads.Cloudinary
# @upload_provider PetalPro.FileUploads.S3
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:form, fn -> %File{} |> Files.change_file() |> to_form() end)
|> assign(:uploaded_files, [])
|> allow_upload(:new_file,
# SETUP_TODO: Uncomment the line below if using an external provider (Cloudinary or S3)
# external: &@upload_provider.presign_upload/2,
accept: ~w(.jpg .jpeg .png .gif .svg .webp),
max_entries: 1
)
{:ok, socket}
end
# ...
end
```
As you can see, this can be configured to work with an external uploader instead. You can use one of the built-in providers (Cloudinary or S3).
In the case of a local server upload, Cloudinary or Amazon S3 - the Content Security Policy has been pre-configured and should work out of the box.
---
# Image Uploads
# Source: https://petal.build/petal-pro/fundamentals/image-uploads
# Image uploads
Allow images to be uploaded to the cloud
Live view has the ability to upload files. We have created a component to help with uploading a single image. This is useful for things like a user's avatar.
```elixir
```
The `image_input` component handles the upload UI, including a preview of the current image and a delete button.
For the full example, check out `edit_profile_live.ex` and `edit_profile_live.html.heex`.
### Choosing an image upload hosting provider
We have provided configuration for two hosting options: [Amazon S3](https://aws.amazon.com/s3/) and [Cloudinary](https://cloudinary.com/).
#### Amazon S3
Follow the instructions in `lib/petal_pro/file_uploads/s3.ex` to get started.
#### Cloudinary
Follow the instructions in `lib/petal_pro/file_uploads/cloudinary.ex`.
#### Our recommendation
We recommend Cloudinary over S3 due to the fact it can do transforms on the fly simply by manipulating the URL. To do transforms in Elixir, you can use the [cloudex library](https://github.com/smeevil/cloudex).
For example, you can do the following:
```shell
iex> Cloudex.Url.for("a_public_id")
"//res.cloudinary.com/my_cloud_name/image/upload/a_public_id"
iex> Cloudex.Url.for("a_public_id", %{width: 400, height: 300})
"//res.cloudinary.com/my_cloud_name/image/upload/h_300,w_400/a_public_id"
iex> Cloudex.Url.for("a_public_id", %{
crop: "fill",
fetch_format: 'auto',
flags: 'progressive',
width: 300,
height: 254,
quality: "jpegmini",
sign_url: true
})
"//res.cloudinary.com/my_cloud_name/image/upload/s--jwB_Ds4w--/c_fill,f_auto,fl_progressive,h_254,q_jpegmini,w_300/a_public_id"
```
Notice you work with "public_ids" - these are ids provided by Cloudinary. You should store these in the database instead of the URL. For example:
```elixir
cloudinary_public_ids =
consume_uploaded_entries(socket, :avatar, fn %{fields: fields}, _entry ->
{:ok, fields["public_id"]}
end)
public_id = hd(cloudinary_public_ids)
# save public_id to the database
```
---
# Included Pages
# Source: https://petal.build/petal-pro/fundamentals/included-pages
# Included Pages
While screenshots in this section are in dark mode, every page also has a light mode.
## Public pages
### Landing page
We have provided a clean, animated landing page with editable components to get you started. It is composed of a number of separate components defined in `lib/components/landing_page.ex` and supports light and dark mode.
Note that this page has some unique CSS and Javascript to help with animations. We use [skypack](https://www.skypack.dev/) to avoid a build system and prevent polluting our `app.js` - see the `PetalPro.Components.LandingPage.javascript/1` function.
Over time we hope to build a comprehensive set of landing page components that you can mix and match.
### Auth pages
All auth pages share a consistent design. These have come from `mix phx.gen.auth` but are styled with Tailwind and Petal Components:
- Register page
- Sign in page
- Forgot password page
## Protected pages
### User dashboard page
Upon signing in users will see a dashboard page on the `/app` route. This uses the **sidebar** layout. By default this shows the organizations the user is in. However this can be modified to whatever you like in `dashboard_live.ex`.
### User onboarding page
When a user signs in for the first time, they will be presented with an onboarding page. This is useful for either collecting more information about a user or displaying a guide to using your web application.
The way this is tracked is by looking at `user.is_onboarded`. Upon onboarding this field will be set to true and won't be presented with this page again.
The redirect happens in a plug defined in the router:
```elixir
defp onboard_new_users(conn, _opts) do
if conn.assigns[:current_user] && !conn.assigns.current_user.is_onboarded do
conn
|> redirect(to: Routes.live_path(conn, PetalProWeb.UserOnboardingLive))
|> halt()
else
conn
end
end
```
You can pipe any set of routes through this plug like so:
```elixir
scope "/", PetalProWeb do
pipe_through [:browser, :onxboard_new_users]
# routes go here
end
```
### User settings pages
The user settings area contains several sub-pages for managing account details.
#### Edit profile
Where users can modify their profile. By default, it will just be the user's name. You may add in other fields here over time like social profiles, avatar upload and user bio.
#### Change email
A user can submit an email change request here. This stores a `user_token` in the database that matches to a URL sent in a confirmation email. If the user clicks the confirmation email, the email will be changed.
#### Edit password
No further explanation needed.
#### Edit notifications
Users can subscribe or unsubscribe from notification types, and now (v4) toggle in-app and email delivery independently for each type. Security-critical notifications — such as password change alerts — are forced-on and cannot be disabled by the user.
By default a "Marketing notifications" type is included, which you can rename to something like "Product updates". Add new notification types as your app grows (e.g. "Comment replies", "Weekly digest") and they'll appear here automatically.
#### Your Data
Available at `/app/users/your-data`, this page covers GDPR compliance for your users:
- **Data export** — downloads a JSON file containing all personal data associated with the account (profile, orgs, logs, notifications, etc.)
- **Account deletion** — submits a deletion request that runs as a background Oban job, performing soft-delete and anonymization cascade across related records
When you add new user-associated schemas, update `PetalPro.Accounts.GDPR` to include them in `export_user_data/1` and `execute_data_deletion/1`.
### Feedback widget
A feedback button is available on every authenticated page. Users can submit text feedback without navigating away from what they're doing. Submissions are stored and surfaced in the admin dashboard.
### Organization pages
```elixir
live "/org/new", NewOrgLive
live "/org/:org_slug", OrgDashboardLive
live "/org/:org_slug/edit", EditOrgLive
live "/org/:org_slug/team", OrgTeamLive, :index
live "/org/:org_slug/team/invite", OrgTeamLive, :invite
live "/org/:org_slug/team/memberships/:id/edit", OrgTeamLive, :edit_membership
```
We have provided a basic CRUD for organizations. This is useful if you're building an app where people are part of any kind of organization (eg. a company or club). An organization member can be an admin or ordinary member. Members can invite new members. Admins can manage memberships. You can read more in the [organizations documentation](/petal-pro/fundamentals/organizations-and-multitenancy).
### Admin pages
#### User management page
The admin user management page shows a list of your apps users. You can search users by name/email/ID and edit their fields.
#### Logs page
The admin logs page lets you view user activity. When a user does something on your platform a new log is created. It is up to you how many actions you want to log. By default the following actions are logged:
```elixir
defmodule PetalPro.Logs.Log do
...
@action_options [
"update_profile",
"register",
"sign_in",
"passwordless_pin_sent",
"passwordless_pin_too_many_incorrect_attempts",
"sign_out",
"confirm_email",
"request_new_email",
"confirm_new_email",
"delete_user",
"orgs.create",
"orgs.delete_member",
"orgs.update_member",
"orgs.create_invitation",
"orgs.delete_invitation",
"orgs.accept_invitation",
"orgs.reject_invitation"
]
...
end
```
The logs page simply allows an admin user to browse these logs. There's also an option to turn on "live logs", where the logs table will update as users perform actions in real time.
#### Admin: Feedback
An admin dashboard for reviewing and managing user feedback submissions. Entries show the submitting user, timestamp, and feedback content. Admins can mark items as reviewed or delete them.
#### Admin: AI Chat
An AI chat interface powered by Jido. Admins can ask questions or issue commands in natural language — the AI dispatches to registered tools that can look up users, manage orgs, fetch stats, publish changelog entries, and more.
Tools live in `PetalPro.Ai.Tools`. To add a custom tool, implement the `PetalPro.Ai.Tool` behaviour and add the module to the registry.
#### Admin: MCP Server
An MCP (Model Context Protocol) endpoint at `POST /api/mcp` exposes the same admin tools to external AI clients via JSON-RPC 2.0. This means you can connect Claude Code or Claude Desktop directly to your running application.
**Setup:**
Set `MCP_ADMIN_TOKEN` in your environment, then add the server to `.mcp.json`:
```json
{
"mcpServers": {
"my-app-dev": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4000/api/mcp"],
"env": {
"MCP_BEARER_TOKEN": "your-token-here"
}
}
}
}
```
Once connected, your AI assistant can query and manage your app without leaving the editor.
### Dev pages
#### Email templates
This page shows a list of all transactional emails. Allows developers to see what emails look like without having to continually send them.
#### Sent emails
A viewer for sent emails. This comes with Swoosh, the email sending library. We've just housed it in the dev section.
#### DB Schema Visualizer
An interactive Mermaid ER diagram of your database schema, grouped by domain. Tables are pan/zoom-able. Each node shows column names with PK/FK indicators. Useful for onboarding new team members and exploring how the pieces fit together.
Only available in the dev environment.
#### Route Tree
A hierarchical, collapsible tree view of all routes in the application, mirroring the URL path structure. Each node shows HTTP verb badges and a click-to-copy helper for the full path. Much easier to scan than `mix phx.routes` when the route table gets long.
Only available in the dev environment.
---
# Customization Recipes
# Source: https://petal.build/petal-pro/fundamentals/customization-recipes
# 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" />
"""
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" />
"""
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 %>!
<.text>Thanks for signing up. Here's what to do next...
<.button url="https://yourapp.com/app">Get Started
```
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.
---
# Components
# Source: https://petal.build/petal-pro/fundamentals/components
# Components
The majority of components come from our open-source package "[Petal Components](https://github.com/petalframework/petal_components)". However, there are some only in Petal Pro. Ultimately, petal.build will house all docs related to components, and these guides will more tailored to Petal Pro core functionality.
### Layouts
[Docs](https://petal.build/components/layouts) are on petal.build.
### Brand
See the [Branding section](/petal-pro/fundamentals/branding).
### Color scheme switch
```heex
<.color_scheme_switch />
```
The switch to switch between light and dark mode.
Note that for this to work you also need to add this to your `` . This is installed by default in Petal Pro.
```heex
# in your :
<.color_scheme_switch_js />
```
### Email helpers
A set of components for email templates. Kind of like Petal Components, but for emails.
Example usage:
```elixir
Let's verify your account
Touch the button below to verify your account
Verify account
If you didn't create an account with us, please ignore this.
```
If you want to add your own components, you can edit the following file:
```elixir
/lib/petal_pro_web/components/email_components.ex
```
Then add your components:
```heex
defmodule PetalPro.Components.EmailComponents do
use Phoenix.Component
def my_new_component(assigns) do
~H"""
Your component HTML goes here
"""
end
end
```
Now it'll be available in your email templates!
```heex
Let's verify your account
<.my_new_component />
```
### Landing page
A set of landing page related components. For example:
```elixir
```
### Language select
A dropdown for setting your language.
```heex
<.language_select
current_locale={Gettext.get_locale(PetalProWeb.Gettext)}
language_options={PetalPro.config(:language_options)}
/>
```
This is a Petal Pro component. If you want to see how it's coded or modify it to your own needs you can edit this file:
```elixir
/lib/petal_pro_web/components/pro_components/language_select.ex
```
Language options are set in your config.exs:
```css
config :petal_pro, :language_options, [
%{locale: "en", flag: "🇬🇧", label: "English"},
%{locale: "fr", flag: "🇫🇷", label: "French"}
]
```
See [translations](/petal-pro/fundamentals/translations) for more info.
### Notification
A flash notification component defined in Petal Pro. See [docs](https://petal.build/components/flash).
The flash notification includes an animated progress bar at the top that advances to the right — once it completes, the notification automatically dismisses.
This comes from `flash_group/1` set in your layout:
To see the implementation:
### Page components
A set of generic page components to help with building pages. To see a list of them:
#### Box
#### Page header
Shows a heading and optional slot for buttons on the right hand side.
#### Sidebar tabs container
A panel with a sidebar for a menu, and a slot for content on the right.
### User dropdown menu
A Petal Pro component. Displays the user's avatar and will show a dropdown menu upon being clicked. Used in the navbar. Displays a list of menu items (see the [Menus section](/petal-pro/fundamentals/layouts-and-menus#menus)).
```heex
<.user_menu_dropdown
user_menu_items={@user_menu_items}
avatar_src={@avatar_src}
current_user_name={if @current_user, do: user_name(@current_user), else: nil}
/>
```
To see the implementation:
```elixir
/lib/petal_pro_web/components/pro_components/user_dropdown_menu.ex
```
### Markdown
#### Pretty markdown
Renders the markdown as HTML and uses the [Tailwind Typography](https://tailwindcss.com/docs/typography-plugin) classes to prettify it.
```heex
<.pretty_markdown content={some_markdown} class="mx-auto" />
```
To see the implementation:
```elixir
/lib/petal_pro_web/components/pro_components/markdown.ex
```
#### Markdown
Simple renders pure HTML from markdown.
```heex
<.markdown content={some_markdown} />
```
### Social Button
```heex
<.social_button
link_type="a"
to={Routes.user_ueberauth_path(@conn_or_socket, :request, "google")}
variant="outline"
logo="google"
class="w-full"
/>
<.social_button
link_type="a"
to={Routes.user_ueberauth_path(@conn_or_socket, :request, "github")}
variant="outline"
logo="github"
class="w-full"
/>
```
This is a Petal Pro component. To see its implementation:
```elixir
/lib/petal_pro_web/components/pro_components/social_button.ex
```
---
# Branding
# Source: https://petal.build/petal-pro/fundamentals/branding
# Branding
## Logos
If you open `core_components.ex` you will find some functional components for your logo. You can use them in your templates like so:
```heex
<.logo />
<.logo variant="light" />
<.logo variant="dark" />
<.logo class="h-20" />
<.logo_icon />
<.logo_icon variant="light" />
<.logo_icon variant="dark" />
<.logo_icon class="h-20" />
```
For this to work with your own logo, you will need to replace the Petal logos:
- `/priv/static/images/logo_dark.svg`
- `/priv/static/images/logo_light.svg`
- `/priv/static/images/logo_icon_dark.svg`
- `/priv/static/images/logo_icon_light.svg`
- `/priv/static/images/favicon.png`
- `/priv/static/images/open-graph.png`
#### Using Figma to create your assets quickly
We created a [Figma file](https://www.figma.com/community/file/1139155923924401853) to easily create and import these assets. Just replace the icon and text with your own brand, and Figma will export all 6 files for you to place straight into your `/priv/static/images` folder.
There are more detailed instructions inside the [Figma file](https://www.figma.com/community/file/1139155923924401853) for you.
## Colors
Petal Pro and Petal Components expects you to define a **primary** and **secondary** color. These typically represent your brand.
In the file `/assets/tailwind.config.js` you will see the primary and secondary colors defined:
```elixir
const colors = require("tailwindcss/colors");
module.exports = {
content: [
"../lib/*_web/**/*.*ex",
"./js/**/*.js",
"../deps/petal_components/**/*.*ex",
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: colors.blue,
secondary: colors.pink,
},
},
},
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
};
```
By default the primary color is blue, and the secondary is pink. You can check out a list of Tailwind color options [here](https://tailwindcss.com/docs/customizing-colors). If you want a custom color, you can check out [tailwind.ink](https://tailwind.ink/).
---
# Dark Mode
# Source: https://petal.build/petal-pro/fundamentals/dark-mode
# Dark mode
If you're new to supporting dark mode in your Phoenix applications, then when you start adding new templates and components you might get frustrated when you forget to add the dark Tailwind classes.
You will need to decide at the start of your project "am I going to support toggling between light and dark?".
### If you decide to support both light and dark color schemes
You will need to ensure you add `dark` variants to any new HTML you write. It's usually quite simple.
[Tailwind colors](https://tailwindcss.com/docs/customizing-colors) usually go from 50 (lightest) to 900 (darkest). When you add a color to your element, all you need to do is write a corresponding dark mode equivalent. For example:
```elixir
No dark support
Dark support
No dark support
Dark support
```
As you can see, it's just a matter of matching the number from the opposite end.
- Light 100 needs a dark 900.
- Light 600 needs a dark 400
One issue we've found is with `text-gray-500`. Instinctively you'd just leave this as it is. But we've found that it's hard to read in dark mode, so we recommend: `text-gray-500 dark:text-gray-400`.
ℹ️ Information: All Petal Components support both light and dark out of the box
### Removing support toggling light and dark
If you can't be bothered supporting both, then you can pick one and run with it.
#### I want light mode only
Open up `root.html.heex` and ensure the `` class doesn't have "dark" in it (by default it shouldn't be there anyway).
Do a global search for `color_scheme_switch` and remove all references to this component. By default, it should just be in `layout.ex`. Remove all the switch components so users cannot toggle modes.
Now, it should always be in light mode.
#### I want dark mode only
Open up `root.html.heex` and ensure the `` class includes "dark" in it.
```elixir
...
```
Do a global search for `color_scheme_switch` and remove all references to this component. By default, it should just be in `layout.ex`. Remove all the switch components so users cannot toggle modes.
Now, it should always be in dark mode.
---
# Layouts & Menus
# Source: https://petal.build/petal-pro/fundamentals/layouts-and-menus
# Layouts & Menus
## The layout component
Petal Pro gives the developer a `<.layout>` component, which takes a `type` attribute and renders a layout based on that `type`. Eg:
```heex
<.layout current_page={:dashboard} current_user={@current_user} type="sidebar">
<.container max_width="xl">
content
```
It works by a simple case statement:
layout.ex
```heex
...
<%= case @type do %>
<% "sidebar" -> %>
<.sidebar_layout {assigns}>
...
<% "public" -> %>
<.public_layout {assigns}>
...
<% end %>
...
```
You can maintain this file and add/remove layouts as you please.
### How to modify an existing layout
Petal Pro provides two app layouts: `"sidebar"` and `"public"`. These are quite configurable, but you may want to modify them further. To do so, edit or duplicate the layout file at:
```
/lib/petal_pro_web/components/pro_components
```
Here you can find `sidebar_layout.ex` and the public layout file.
### How to add a new layout
1. Copy `_web/components/pro_components/sidebar_layout.ex`, eg: `my_cool_layout.ex`
2. Review assigns and adjust your new layout — you can see in `layout/1` (`core_components.ex`) how menu items are passed in as well as the user's name and avatar
3. When complete, import your layout into `core_components.ex` and modify the `layout/1` function — eg:
```heex
<%= case @type do %>
<% "my-cool-layout" -> %>
<.my_cool_layout {assigns}>
```
4. Now in your template or live view file, simply update the type to your new layout:
```heex
<.layout type="my-cool-layout" current_page={:dashboard} current_user={@current_user}>
<.container>
content
```
## Petal Pro Layouts
### Sidebar layout
This is the only app layout in v4 — the stacked (desktop header) layout has been removed. The sidebar supports a large number of menu items and works on all screen sizes.
```heex
<.layout current_page={:dashboard} current_user={@current_user} type="sidebar">
<.container>
content
```
The sidebar layout features a full-height navigation panel on desktop. On mobile, the sidebar collapses behind a hamburger menu that slides in as an overlay.
#### Sidebar collapse
The sidebar can be collapsed to a narrow icon-only rail. The collapse button is designed as a flush edge tab with directional chevrons: it points left when the sidebar is expanded (indicating you can collapse it) and right when collapsed (indicating you can expand it).
Collapse state persists across page loads via Alpine.js `$persist()`, which stores the preference in `localStorage`. There are no flash or layout-shift issues on load because Alpine initialises the state synchronously from storage before the first paint — deferred transitions are used to prevent any animation flash.
On mobile (`lg:hidden`), the sidebar works differently: a sticky top bar with a hamburger button slides the sidebar in as an overlay rather than compressing the main content area.
### Public layout
Includes a header and footer — for use in public-facing, marketing pages. The public layout is available in `router.ex` via the `:public_layout` pipeline:
```elixir
# Public routes
scope "/", PetalProWeb do
pipe_through [:browser, :public_layout]
get "/", PageController, :landing_page
get "/privacy", PageController, :privacy
get "/license", PageController, :license
live_session :public, layout: {PetalProWeb.Layouts, :public} do
# Add public live routes here
end
end
```
The public layout supports both light and dark mode out of the box.
## Menus
In `menus.ex` there is a list of all the menu items. This can be useful when working with navbars and layouts, where you sometimes need to loop over menu items multiple times.
For all menu items, you must define a new `get_link/2` function, which takes a `name` and `current_user` as the parameters. The `name` parameter is pattern matched and thus you statically include it in the function definition. A menu item has a name, label, path, and icon. Here's an example:
```elixir
def get_link(:register, _current_user) do
%{
name: :register,
label: "Register",
path: Routes.user_registration_path(Endpoint, :new),
icon: "hero-clipboard-list"
}
end
```
You can use the `current_user` param to conditionally display a menu item. For example, here we only show the item to admins:
```elixir
def get_link(:admin_users = name, current_user) do
if Helpers.is_admin?(current_user) do
%{
name: name,
label: "Users",
path: Routes.admin_users_path(Endpoint, :index),
icon: "hero-users"
}
else
nil
end
end
```
You can build menu item lists to give to layouts. The two core menu lists — one for the sidebar and one for the public header — are defined in `menus.ex` and passed to the layout component as assigns.
Never hardcode menu items inline in templates — always define them in `PetalProWeb.Menus`.
---
# Generators
# Source: https://petal.build/petal-pro/fundamentals/generators
# Generators
## Generators
### mix petal.gen.live
This produces the same files as Phoenix's `mix phx.gen.live` ([docs](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Live.html)). The only difference is that the templates use Tailwind and Petal Components. You can try it out with:
```shell
mix petal.gen.live Todos Todo todos label:string is_done:boolean
```
ℹ️ Information: The default implementation uses the Table component and Streams. The following variation will generate an alternate template using the Data Table component (without Streams):
``shell
mix petal.gen.live Todos Todo todos label:string is_done:boolean --data-table
``
Add the routes from the output.
ℹ️ Information: If you're wondering where to put the routes, you can search in the router file for the text "Add live authenticated routes here", and add them straight after that line.
```elixir
defPetalProWeb.Routerdo
...
scope "/",PetalProWebdo
pipe_through [
:browser,
:require_authenticated_user,
:kick_user_if_suspended_or_deleted,
:onboard_new_users
]
live_session :require_authenticated_user,
on_mount:{PetalProWeb.UserOnMountHooks,:require_authenticated_user}do
...
# Add live authenticated routes here
live "/todos",TodoLive.Index,:index
live "/todos/new",TodoLive.Index,:new
live "/todos/:id/edit",TodoLive.Index,:edit
live "/todos/:id",TodoLive.Show,:show
live "/todos/:id/show/edit",TodoLive.Show,:edit
end
end
...
end
```
After running the generator and adding the routes, you'll have a fully functional CRUD interface: an index screen listing all todos, a form for creating new todos, and a show page for viewing individual records. All screens come pre-styled with Tailwind and Petal Components.
### mix petal.gen.html
Same file output as `phx.gen.html` ([docs](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Html.html)), but uses Petal Components and a layout from Pro.
Example:
```shell
mix petal.gen.html Todos Todo todos label:string is_done:boolean
```
---
# JavaScript Hooks
# Source: https://petal.build/petal-pro/fundamentals/javascript-hooks
# Javascript Hooks
## Hook structure
We have set up a structure for you that makes it easy to keep adding hooks. To add a new hook:
1. `touch /assets/js/hooks/my-hook.js`
2. Make sure you export it:
```javascript
const MyHook = {
// ...
};
export default MyHook;
```
3. In `hooks/index.js`, import it:
```javascript
import MyHook from "./my-hook";
export default {
MyHook,
};
```
It will now be available to use in your HEEx templates:
```heex
```
Note that in dead views, only the `mounted()` callback will be run.
## Hooks from Petal Pro
### RemoveFlashHook
Pairs with the `<.flash_group>` component and will auto-dismiss a flash message after a period of time (10 seconds).
### ResizeTextareaHook
Textareas can be annoying when your content exceeds the height and a scrollbar appears. This hook auto-resizes the textarea as you type so it never shows a scrollbar and keeps getting bigger as you add more content.
### ScrollTopHook
Useful when you have a LiveView with multiple "pages" navigated via `live_patch` links. Without this, clicking a `live_patch` link while scrolled down won't reset the scroll position, leaving the user disoriented. Place `phx-hook="ScrollTopHook"` on the `live_patch` link and it will scroll to the top on click.
## New hooks in v4
### InfiniteScrollHook
Triggers a `load_more` LiveView event when a sentinel element scrolls into view. Uses `IntersectionObserver` with a 200px root margin so content loads slightly before the user reaches the bottom.
**Usage:**
```heex
```
In your LiveView, handle the event:
```elixir
def handle_event("load_more", %{"page" => page}, socket) do
page = String.to_integer(page)
items = MyContext.list_items(page: page + 1)
{:noreply, stream(socket, :items, items)}
end
```
The hook re-observes the element after each LiveView patch, so it works correctly with streams.
### ScrollToBottomHook
Auto-scrolls a container to the bottom on mount and after every update. The canonical use case is a chat window where new messages should always be visible.
**Usage:**
```heex
<%= message.body %>
```
### ScrollToHashHook
On mount, smoothly scrolls to the element matching the URL hash (`window.location.hash`). Useful for documentation pages, settings pages, or any page with anchor links.
**Usage:** place the hook on the page root element or any wrapper:
```heex
......
```
Linking to `?#billing` will cause the page to smooth-scroll to that section on load.
### SelectAllHook
Implements the "select all" checkbox pattern for bulk-action tables. When the hook's element is checked, it sets all matching row checkboxes to the same state and fires a `change` event on each so LiveView bindings pick it up.
**Data attribute:**
- `data-target` — CSS selector for the row checkboxes (required)
**Usage:**
```heex
<%!-- Select-all control in the table header --%>
<%!-- Per-row checkboxes --%>
```
### ShareHook
Uses the Web Share API to trigger the native OS share sheet on mobile. Falls back to copying the URL to the clipboard on desktop browsers that don't support `navigator.share`. On clipboard copy, it pushes a `link_copied` event to LiveView so you can show a flash message.
**Data attributes (all optional):**
- `data-title` — share title (defaults to `document.title`)
- `data-text` — share body text
- `data-url` — URL to share (defaults to `window.location.href`)
**Usage:**
```heex
```
Handle the clipboard fallback in LiveView:
```elixir
def handle_event("link_copied", _params, socket) do
{:noreply, put_flash(socket, :info, "Link copied to clipboard!")}
end
```
---
# Translations
# Source: https://petal.build/petal-pro/fundamentals/translations
# Translations
If you're new to translations then please take a look at our [Twitter thread](https://twitter.com/PetalFramework/status/1496216170084864000) explaining the basics. Also check out:
- the [hex docs for Gettext](https://hexdocs.pm/gettext/Gettext.html)
- a good [blog post](https://phrase.com/blog/posts/i18n-for-phoenix-applications-with-gettext/) explaining Gettext
In a nutshell, you run text that you want translated through the `gettext` function and then add in your translations. These are found in `priv/gettext`. The `.pot` files are auto-generated - you just need to modify the files in the `LC_MESSAGES` folders.
Note that you should only modify the files in the `LC_MESSAGES` folders — the `.pot` files are auto-generated.
We've tried to implement `gettext` throughout the public facing areas of the boilerplate (we may have missed some places - feel free to point them out to us or a PR would be very helpful).
### How to add update your pot/po files after adding more gettexts
When you add new `gettext` calls, your translation files won't show them until this command is run:
```shell
mix gettext.extract --merge
```
Or if you can use our easier-to-remember alias (aliases are defined in the `mix.exs` file):
```shell
mix update_translations
```
Then go and add your translations to the files in the `LC_MESSAGES` folders for each language.
### How to add/remove languages
Modify the languages in the `confix.exs` file:
config.exs
```css
config :petal_pro, PetalProWeb.Gettext, allowed_locales: ~w(en fr)
config :petal_pro, :language_options, [
%{locale: "en", flag: "🇬🇧", label: "English"},
%{locale: "fr", flag: "🇫🇷", label: "French"}
]
```
If you're adding a new language, you'll need to run this (keep in mind `fr` means French - change this to whatever language you're adding - here's [a list of them all](https://www.gnu.org/software/gettext/manual/html_node/Usual-Language-Codes.html)):
```shell
mix gettext.merge priv/gettext --locale fr
```
To delete a language, remove it from the config and delete the corresponding folder in `priv/gettext`.
---
# Impersonation
# Source: https://petal.build/petal-pro/fundamentals/impersonation
# Impersonation
Admins can sign in as any user
In version 1.6 ([pull request](https://github.com/petalframework/petal_pro/pull/152)) we added the ability for admins to impersonate normal users.
From the admin panel, admins can impersonate any normal user. This adds two routes:
```elixir
post "/impersonate",UserImpersonationController,:create
delete "/impersonate",UserImpersonationController,:delete
```
Clicking the "Impersonate" button will perform a POST request to the above route, including the user_id of the user to be impersonated. We store the `current_admin_id` in the session, and sign in the admin as that user.
Once the admin has finished, the sign out button will be replaced with an "Exit impersonation" button.
This button will hit the `:delete` action route, which looks for `current_admin_id` in the session and uses it to sign in to the admin user again.
#### Turn off impersonation
You can toggle impersonation on/off easily in `config.exs`:
```css
config :petal_pro,
impersonation_enabled?: false
```
---
# AI & Developer Tooling
# Source: https://petal.build/petal-pro/fundamentals/ai-admin-chat
# AI & Developer Tooling
Petal Pro v4 includes a suite of AI-powered admin tools and first-class Claude Code integration. This page covers the in-app AI chat, the MCP server for external AI clients, and the Claude Code configuration that ships with every project.
## AI Admin Chat
The AI Admin Chat is an admin-only chat interface that lets you interrogate your running Petal Pro app using natural language. Ask it to pull stats, look up users, manage orgs, or check AI usage costs — it figures out which tools to call, chains multiple calls if needed, and presents the results as readable text. It's powered by [Jido](https://github.com/agentjido/jido) and [ReqLLM](https://hexdocs.pm/req_llm) using Google Gemini, and shares the same action registry as the [Admin MCP Server](#admin-mcp-server).
Conversations are saved to the database and survive page reloads and server restarts. The sidebar lists recent chats so you can pick up where you left off.

> **v4 migration note:** The AI Admin Chat was rewritten in v4 to use Jido and ReqLLM (replacing the LangChain-based implementation from v3). If you added custom LangChain tools in v3, you'll need to reimplement them as native `Jido.Action` modules described below. The tool surface is the same; only the implementation pattern changed.
### Architecture
```
Admin LiveView
│
▼
PetalPro.AI.Actions.AdminChatAction (Jido.Action)
│ streams responses from Gemini via ReqLLM
│ runs tool loop until no more tool calls
▼
PetalPro.AI.AdminChat.ActionRegistry (single source of registered actions)
│ Jido.AI.Turn validates args + dispatches by name
▼
PetalPro.AI.AdminChat.Actions.* (individual Jido.Action modules)
▼
Your Repo / Contexts
```
The default model is `google:gemini-2.5-flash-lite`, overridable via the `:ai_admin_chat_model` app env. Every call goes through `PetalPro.AI.HTTP.client/1`, which attaches `PetalPro.AI.CostTrackerPlugin` as a Req response step — token counts and cost are logged to `ai_call_logs` automatically.
### Tools Are Native Jido Actions
Every tool is a `Jido.Action` module. The action declares its name, description, and a [NimbleOptions](https://hexdocs.pm/nimble_options) schema, and Jido handles argument validation, JSON Schema generation for the LLM, and dispatch.
```elixir
defmodule PetalPro.AI.AdminChat.Actions.ManageUser do
use Jido.Action,
name: "manage_user",
description: "Manage a user account.",
schema: [
user_id: [type: :string, required: true, doc: "The user's UUID."],
action: [
type: {:in, ["suspend", "unsuspend", "delete", "undelete", "make_admin", "remove_admin"]},
required: true,
doc: "Action to perform."
]
]
@impl true
def run(%{user_id: user_id, action: action}, _context) do
# ...
end
end
```
| Field | Purpose |
|---|---|
| `name` | Unique string identifier used in `tools/call` |
| `description` | Shown to the model — write it like a good function docstring |
| `schema` | NimbleOptions keyword list — emits as JSON Schema for the LLM |
| `run/2` | Receives validated, atom-keyed params and a context map |
`:in` constraints emit as JSON Schema `enum` arrays so strict MCP clients see typed choices instead of free-form strings.
### Action Registry
`PetalPro.AI.AdminChat.ActionRegistry` holds the `@actions` list and exposes two helpers used by both the chat LiveView and the MCP server:
```elixir
# ReqLLM-shaped tool definitions (cached)
ActionRegistry.req_llm_tools()
# Action lookup map used by Jido.AI.Turn for dispatch (cached)
ActionRegistry.action_map()
```
Both results are memoised in `:persistent_term`, so the registry has zero per-request overhead.
Action modules live in `lib/petal_pro/ai/admin_chat/actions/`. The full list registered by default:
- `get_site_stats` — user/org counts by period
- `list_recent_users` — recently registered users
- `search_users` — text search with filters
- `manage_user` — suspend, unsuspend, delete, undelete, change role
- `list_orgs` — organizations with member counts
- `manage_org` — rename, delete, list members
- `get_ai_call_stats` — AI usage costs and token breakdown
- `list_changelog_updates` — read changelog entries
- `create_changelog_update` — add a new changelog entry
- `manage_changelog` — update or delete changelog entries
### AI Cost Tracking
Every Gemini call is automatically tracked. The mechanism:
1. `PetalPro.AI.HTTP.client/1` returns a `Req` client with `CostTrackerPlugin` attached.
2. When a response comes back from a known AI provider, the plugin extracts token counts and snapshots the current per-million-token price.
3. An `AICallLog` record is inserted asynchronously via `Task.Supervisor` — cost tracking failure never interrupts the AI call.
4. The `operation` and `user_id` are read from the process dictionary (`:ai_cost_context`).
To set attribution context before making AI calls:
```elixir
Process.put(:ai_cost_context, %{operation: :my_feature, user_id: user.id})
```
`AdminChatAction` already does this automatically (`operation: :admin_chat`).
Logs are viewable in the admin panel under AI → Call Logs, and queryable via the `get_ai_call_stats` tool. The `AICallLog` schema:
```elixir
typed_schema "ai_call_logs" do
field :provider, :string
field :model, :string
field :operation, :string
field :input_tokens, :integer
field :output_tokens, :integer
field :input_price_per_million, :integer
field :output_price_per_million, :integer
field :total_cost_cents, :integer
field :response_time_ms, :integer
field :http_status, :integer
field :error_message, :string
belongs_to :user, PetalPro.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
```
### Daily Budget
The chat action enforces a per-user daily budget (default 500 cents / $5.00). Configure it in `config/config.exs`:
```elixir
config :petal_pro, ai_daily_budget_cents: 500
```
### Configuration
The API key is read from `config/runtime.exs` via the `:jido_ai` keyring:
```elixir
config :jido_ai, keyring: [
google_gemini_api_key: System.get_env("GOOGLE_GEMINI_API_KEY")
]
```
Set `GOOGLE_GEMINI_API_KEY` in your environment. Without it, the action returns a descriptive error rather than crashing.
The model used is `gemini-3.1-flash-lite-preview` (configured in `AdminChatAction`). The action supports up to 8 tool-call iterations per message (configurable via `config :petal_pro, ai_max_tool_iterations: 8`).
## Admin MCP Server
Petal Pro ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-03-26) (MCP) server that exposes your app's admin tools to external AI clients such as Claude Code and Claude Desktop. Instead of logging into an admin panel to look up a user or check billing stats, you can ask your AI assistant directly — it talks to your running app over JSON-RPC 2.0, executes the relevant tool, and hands you back real data.
### The Endpoint
All MCP traffic goes through a single HTTP endpoint:
```
POST /api/mcp
Authorization: Bearer
Content-Type: application/json
```
The server speaks [JSON-RPC 2.0](https://www.jsonrpc.org/specification) and accepts both single messages and batched arrays. Supported methods:
| Method | Description |
|---|---|
| `initialize` | Protocol handshake — returns server name, version, and capabilities |
| `tools/list` | Returns all registered tools with their input schemas |
| `tools/call` | Executes a named tool with arguments |
| `ping` | Health check |
Quick sanity check with curl:
```shell
curl -X POST http://localhost:4000/api/mcp \
-H "Authorization: Bearer dev-mcp-token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
### Setup
#### The Token
The server authenticates via a static bearer token read from the `MCP_ADMIN_TOKEN` environment variable. In dev, a default of `"dev-mcp-token"` is used so you can get started immediately. Set a real secret for staging and production:
```shell
# runtime.exs already reads this — just set the env var
export MCP_ADMIN_TOKEN="your-long-random-secret"
```
On Fly.io:
```shell
fly secrets set MCP_ADMIN_TOKEN="your-long-random-secret"
```
The token is compared with `Plug.Crypto.secure_compare/2` to avoid timing attacks.
#### The Route
The route is already wired in the router. You don't need to add anything. For reference, the controller that handles requests is `PetalProWeb.McpController`.
### Connecting Claude Code
Create a `.mcp.json` file at the root of your project (it's gitignored by default):
```json
{
"mcpServers": {
"petal-pro-dev": {
"type": "http",
"url": "http://localhost:4000/api/mcp",
"headers": {
"Authorization": "Bearer dev-mcp-token"
}
},
"petal-pro-prod": {
"type": "http",
"url": "https://yourapp.fly.dev/api/mcp",
"headers": {
"Authorization": "Bearer your-long-random-secret"
}
}
}
}
```
After saving, run `claude mcp list` or restart Claude Code — the `petal-pro-dev` server should appear. You can now ask things like "list the 5 most recent users" or "suspend user with id abc-123" directly in the chat.
### Connecting Claude Desktop
Add the server to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"petal-pro": {
"type": "http",
"url": "https://yourapp.fly.dev/api/mcp",
"headers": {
"Authorization": "Bearer your-long-random-secret"
}
}
}
}
```
Restart Claude Desktop and the tools will appear in the tools panel.
### Built-in Tools
The following tools ship with Petal Pro. They're all native `Jido.Action` modules under `lib/petal_pro/ai/admin_chat/actions/` and registered in `PetalPro.AI.AdminChat.ActionRegistry`.
#### `get_site_stats`
Returns platform metrics — total users, total orgs, and new signups/orgs for a given period (`today`, `week`, `month`, `all`). Useful for a quick health check.
#### `list_recent_users`
Lists recently registered users with basic profile info.
#### `search_users`
Text search across user accounts with optional status filters.
#### `manage_user`
Performs account management actions on a single user identified by UUID. Available actions:
| Action | Effect |
|---|---|
| `suspend` | Prevents login |
| `unsuspend` | Re-enables login |
| `delete` | Soft-deletes the account |
| `undelete` | Reverses soft-delete |
| `make_admin` | Grants admin role |
| `remove_admin` | Revokes admin role |
All actions are audit-logged via `Logs.log_async/2`.
#### `list_orgs`
Lists organizations with member counts. Accepts an optional search term and result limit.
#### `manage_org`
Manages an organization by slug or UUID. Available actions: `rename` (requires `new_name`), `delete`, `list_members`.
#### `get_ai_call_stats`
Returns AI usage and cost statistics for a configurable lookback period (default 30 days, max 365). Includes total calls, token counts, costs in both cents and dollars, and breakdowns by model and operation.
#### `list_changelog_updates`, `create_changelog_update`, `manage_changelog`
Tools for reading and managing your app's changelog entries from the AI client.
### Adding a Custom Tool
Define a `Jido.Action` module under `lib/petal_pro/ai/admin_chat/actions/`:
```elixir
defmodule MyApp.AI.AdminChat.Actions.GetFeedbackStats do
@moduledoc "Admin chat tool: feedback submission counts by status."
use Jido.Action,
name: "get_feedback_stats",
description:
"Get feedback submission counts grouped by status (open, in_progress, resolved). " <>
"Optionally filter by number of days to look back.",
schema: [
days: [
type: {:or, [:pos_integer, {:in, [nil]}]},
default: nil,
doc: "Look back this many days. Omit to return all-time counts."
]
]
import Ecto.Query
alias MyApp.Feedback.Submission
alias MyApp.Repo
@impl true
def run(%{days: days}, _context) do
base =
if days do
since = DateTime.add(DateTime.utc_now(), -days, :day)
from(s in Submission, where: s.inserted_at >= ^since)
else
Submission
end
counts =
base
|> group_by([s], s.status)
|> select([s], {s.status, count(s.id)})
|> Repo.all()
|> Map.new()
{:ok, %{period_days: days, counts: counts}}
end
end
```
Append it to `@actions` in `PetalPro.AI.AdminChat.ActionRegistry`:
```elixir
@actions [
# ... existing actions ...
MyApp.AI.AdminChat.Actions.GetFeedbackStats
]
```
The tool immediately appears in both the admin chat UI and the MCP server — one registration covers both interfaces. Add a test under `test/petal_pro/ai/admin_chat/actions/` calling `run/2` directly.
### Removing a Tool
Delete the module from `@actions` in `ActionRegistry`. The tool disappears from `tools/list` and `tools/call` at the next compile. You can delete the module file (and its test) too if you don't need it.
### Security Considerations
- **The token is the only auth mechanism.** Treat `MCP_ADMIN_TOKEN` like a root password — use a long random string in production and rotate it if compromised.
- **Tools run with full database access.** Every tool executes in your app process with your Repo. Don't add tools that expose raw SQL execution or unfiltered bulk data.
- **Destructive actions are logged.** `manage_user` and `manage_org` both call `Logs.log_async/2` so you have an audit trail.
- **Don't commit `.mcp.json`.** It's gitignored by default. If you ever accidentally commit it with a production token, rotate the token immediately.
- **Consider network restrictions for production.** If your MCP client only needs to reach production from your own IP, add a firewall rule or Fly.io private networking rather than exposing the endpoint publicly.
## Claude Code Integration
Petal Pro v4 ships with first-class Claude Code support. Sub-agents, skills, recipes, and scoped CLAUDE.md files are all pre-configured — you get an AI-aware codebase out of the box.
### Sub-Agents
Sub-agents are specialized Claude Code agents that automatically activate based on context. They live in `.claude/agents/` and are invoked by Claude Code when relevant.
| Agent | Description |
|-------|-------------|
| `schema-architect` | Designs database schemas following Petal Pro conventions (UUID v7 PKs, proper indexes, on_delete strategies) |
| `test-runner` | Runs tests, interprets failures, and suggests fixes |
| `elixir-reviewer` | Reviews Elixir code for correctness, conventions, and common pitfalls |
| `heex-reviewer` | Reviews HEEx templates for proper interpolation, component usage, and accessibility |
| `ui-ux-reviewer` | Reviews UI/UX for design quality, usability, and consistency with the app's design system |
You don't invoke these directly — Claude Code activates the appropriate agent automatically.
### Skills
Skills are slash commands for scaffolding new code. They enforce project conventions so generated code is consistent with the rest of the codebase. Run them from Claude Code with an optional argument describing what you want.
#### Scaffolding
| Command | Description |
|---------|-------------|
| `/new-liveview` | Scaffolds a new LiveView page — layout wrapping, imports, assign patterns, handle_params/handle_event structure |
| `/new-context` | Scaffolds a new Phoenix context module — CRUD patterns, filters, error handling, logging |
| `/new-email` | Creates a new transactional email template with inline styling and the deliver/render pipeline |
| `/new-oban-worker` | Scaffolds an Oban background worker — queue config, retry strategy, logging, error handling |
| `/schema-migration` | Guides schema and migration changes — UUID v7 PKs, indexes, Ecto schema patterns |
#### Feature Addition
| Command | Description |
|---------|-------------|
| `/add-org-feature` | Scaffolds a new org-scoped resource end-to-end: schema with org_id, context, migration, LiveView pages, routes, GDPR updates |
| `/add-notification` | Adds a new notification type end-to-end: schema, context function, email template, component renderer |
| `/add-admin-page` | Scaffolds an admin section page with DataTable, Flop pagination, sorting, and filtering |
| `/add-api-endpoint` | Adds a new REST API endpoint with OpenAPI specs, bearer auth, JSON views, and tests |
| `/add-billing-feature` | Guides through billing additions: new plans, subscription gates, webhook handlers, Stripe calls |
| `/add-route` | Safely adds routes to the router — enforces live_session grouping, pipeline selection, scope conventions |
#### Utilities
| Command | Description |
|---------|-------------|
| `/ci` | Simulates the full GitHub Actions CI pipeline locally and fixes failures |
| `/gdpr-compliance` | Ensures new user-associated schemas are included in data export and deletion workflows |
| `/liveview-js` | Guides use of `Phoenix.LiveView.JS` for client-side UI state (show/hide, toggles, tabs) instead of server round-trips |
### Recipes
Recipes are slash commands for larger-scale customizations — removing features, switching architecture, adding integrations. See the [Recipes](/docs/recipes) page for the full list.
### CLAUDE.md Files
The project ships with scoped `CLAUDE.md` files at multiple levels. Claude Code reads these automatically when working in each directory, giving it the right context without you having to explain it every time.
| File | Scope |
|------|-------|
| `CLAUDE.md` (root) | Project overview, feature table, dev scripts, architectural patterns, config keys |
| `lib/CLAUDE.md` | Elixir conventions, Ecto guidelines, Mix guidelines |
| `lib/petal_pro/CLAUDE.md` | Business logic patterns, multi-tenancy, auth, billing, background jobs |
| `lib/petal_pro_web/CLAUDE.md` | Web layer conventions, LiveView patterns, layout system |
| `assets/CLAUDE.md` | JS/CSS conventions, Tailwind v4 setup, Alpine.js patterns |
| `test/CLAUDE.md` | Testing conventions, Mimic mocking, factory patterns |
### How to Use
#### Installation
```shell
npm install -g @anthropic-ai/claude-code
```
#### Starting a session
```shell
cd your-petal-pro-project
claude
```
Claude Code picks up all the CLAUDE.md context files automatically.
#### Running a skill
Type the slash command with an optional description of what you want:
```
/new-liveview A settings page for managing notification preferences
```
```
/add-org-feature Projects — users can create and manage projects within their org
```
#### Running a recipe
```
/recipes:remove-billing
```
#### Using sub-agents
Sub-agents activate automatically — when you ask Claude Code to review a template it will invoke `heex-reviewer`, when you ask it to design a schema it will use `schema-architect`. You don't need to call them explicitly.
#### Asking Claude Code to work on a task
The workflow commands handle the full development cycle:
```
/plan Add a feedback widget to the dashboard
```
This generates a structured task file. Then:
```
/do_task
```
This implements it. Then:
```
/push
```
This runs CI, commits, and pushes.
---
# Feedback & Analytics
# Source: https://petal.build/petal-pro/fundamentals/feedback-system
# Feedback & Analytics
Petal Pro includes an in-app feedback system and a lightweight server-side page analytics system. Together they give you qualitative user input and quantitative usage data — all without external services.
## Feedback System
Users submit feedback via a floating widget (available on any LiveView) or the dedicated feedback page at `/app/feedback`. Submissions are stored in the `feedbacks` table via `PetalPro.Feedback.FeedbackItem`. Admins review submissions at `/admin/feedback`. Both authenticated and anonymous submissions are supported.
### Schema
`PetalPro.Feedback.FeedbackItem` has these fields:
| Field | Type | Notes |
|-------|------|-------|
| `message` | string | Required. The feedback text. |
| `category` | string | One of `general`, `bug`, `feature`, `question`. Defaults to `general`. |
| `page_url` | string | The URL the user was on when they submitted. Optional. |
| `user_id` | uuid | Nullable. Nil for anonymous submissions. |
### Context module
`PetalPro.Feedback` is the public interface:
```elixir
# Create feedback for an authenticated user
Feedback.create_feedback(%{"message" => "Love it", "category" => "general"}, user)
# Create anonymous feedback (no user association)
Feedback.create_feedback_anonymous(%{"message" => "Found a bug", "category" => "bug"})
# Fetch for admin review
Feedback.list_feedbacks()
Feedback.get_feedback!(id)
Feedback.delete_feedback(feedback_item)
```
### Adding the feedback widget
`PetalProWeb.FeedbackComponent` renders a floating button (bottom-right corner) that toggles a slide-up panel with the feedback form. Add it to any LiveView template:
```heex
```
The widget works for both authenticated and anonymous users — pass `current_user={nil}` for public pages.
After a successful submission the LiveComponent sends `{:feedback_submitted, feedback}` to the parent LiveView. Handle it if you want to show a flash:
```elixir
def handle_info({:feedback_submitted, _feedback}, socket) do
{:noreply, put_flash(socket, :info, "Thanks for your feedback!")}
end
```
#### Dedicated feedback page
There is also a standalone page at `/app/feedback` rendered by `PetalProWeb.FeedbackLive`. Users reach it via the sidebar menu. It uses the same `FeedbackFormComponent` as the widget, so behaviour is identical.
### Admin dashboard
`PetalProWeb.AdminFeedbackLive` lives at `/admin/feedback`. It uses the `DataTable` component (Flop-backed) with:
- **Sortable columns**: category, submitted date
- **Filterable columns**: category (exact match)
- **Full-text search**: across `message` and `category`
- **Per-row delete**: confirmation dialog before removal
- **User link**: authenticated submissions link to the user's admin profile; anonymous submissions show "Anonymous"
Categories are color-coded: bug → red, feature → blue, question → yellow, general → gray.
### Extending the feedback system
#### Adding custom categories
The allowed categories are validated in `FeedbackItem.changeset/2`:
```elixir
|> validate_inclusion(:category, ~w(general bug feature question))
```
Add your category to the validation list and update the options in `FeedbackFormComponent` and `category_color/1` in `AdminFeedbackLive`:
```elixir
# In FeedbackFormComponent render/1
options={[
{"General", "general"},
{"Bug Report", "bug"},
{"Feature Request", "feature"},
{"Question", "question"},
{"Praise", "praise"} # new
]}
# In AdminFeedbackLive
defp category_color("praise"), do: "success"
```
#### Webhook notifications on new feedback
The `Feedback.create_feedback/2` and `create_feedback_anonymous/1` functions return `{:ok, feedback_item}` on success. Wrap the call site in `FeedbackFormComponent` to trigger a side effect, or add an Oban worker that fires after insert:
```elixir
# In FeedbackFormComponent handle_event "submit"
case Feedback.create_feedback(feedback_params, current_user) do
{:ok, feedback} ->
# Fire and forget
Task.start(fn ->
MyApp.Workers.FeedbackWebhookWorker.enqueue(feedback.id)
end)
{:noreply, assign(socket, submitted: true)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
```
The worker can then POST the payload to a Slack webhook, Discord, or any HTTP endpoint using `Req`.
## Page Analytics
Petal Pro ships with a lightweight, server-side page view tracking system. It records view counts per page per day using atomic Postgres upserts — no external service, no JavaScript, no cookies, no PII.
### What it tracks
- **Page name** — a string you define when instrumenting a LiveView (e.g. `"dashboard"`, `"pricing"`)
- **Date** — UTC date of the view
- **View count** — atomically incremented integer per page per day
### What it does not track
- Individual users or sessions
- IP addresses or user agents (used only for bot filtering, never stored)
- Referrers, UTMs, or any request metadata
- Clicks, scroll depth, or client-side events
This makes it safe to use without cookie consent banners. It complements — not replaces — external analytics tools like Fathom or PostHog.
### How daily aggregation works
The schema is `PetalPro.Analytics.DailyPageStat` backed by the `daily_page_stats` table:
```elixir
typed_schema "daily_page_stats" do
field :page_name, :string
field :date, :date
field :view_count, :integer, default: 0
timestamps(type: :utc_datetime)
end
```
There is a unique constraint on `[:page_name, :date]`. Every call to `record_page_view/1` does a Postgres `ON CONFLICT` upsert that increments the counter atomically — safe to call concurrently from many LiveView processes:
```elixir
Repo.insert(
%DailyPageStat{page_name: page_name, date: today, view_count: 1},
on_conflict: [inc: [view_count: 1], set: [updated_at: now]],
conflict_target: [:page_name, :date]
)
```
One row per page per day. No cleanup job needed.
### Instrumenting a page
Use `PetalProWeb.PageViewHelper.track_page_view/2` in your LiveView `mount/3`:
```elixir
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
alias PetalProWeb.PageViewHelper
@impl true
def mount(_params, _session, socket) do
PageViewHelper.track_page_view(socket, "dashboard")
{:ok, socket}
end
end
```
`track_page_view/2` only records a view when:
1. The socket is **connected** (not the initial static render) — prevents double-counting on first load
2. The user agent is **not a bot** — checked against a built-in list of ~30 known bot/crawler patterns including Googlebot, GPTBot, ClaudeBot, curl, and uptime monitors
The actual DB write runs in a `Task.start/1` so it never blocks the LiveView response.
#### Tracking a custom event
You can use `PetalPro.Analytics.record_page_view/1` directly to track anything, not just page loads:
```elixir
PetalPro.Analytics.record_page_view("checkout_started")
PetalPro.Analytics.record_page_view("pricing_page")
```
The `page_name` is just a string — pick a naming convention and stick to it.
### Querying analytics data
`PetalPro.Analytics` exposes three query functions:
```elixir
# Top pages by views for a date range (default limit: 20)
Analytics.top_pages(~D[2025-01-01], ~D[2025-01-31])
# => [%{page_name: "dashboard", total_views: 4201}, ...]
# Daily breakdown for a specific page
Analytics.page_views_by_day("dashboard", ~D[2025-01-01], ~D[2025-01-31])
# => [%{date: ~D[2025-01-01], views: 142}, ...]
# Total views across all pages
Analytics.total_views(~D[2025-01-01], ~D[2025-01-31])
# => 18_432
```
### Viewing analytics in the admin
Page analytics are surfaced in the admin dashboard alongside user acquisition and subscription charts. The dashboard queries `top_pages/3` and renders a chart via the `chart-js-hook`.
### Configuration
No configuration is required. The system is always active. If you don't call `track_page_view/2` on a LiveView, that LiveView simply isn't tracked.
To disable tracking on a specific page, just don't call the helper — or add a conditional:
```elixir
def mount(_params, _session, socket) do
unless Application.get_env(:my_app, :disable_analytics) do
PageViewHelper.track_page_view(socket, "dashboard")
end
{:ok, socket}
end
```
### Difference from Fathom / external analytics
| | Internal page analytics | Fathom (or similar) |
|---|---|---|
| Where it runs | Server-side Elixir | Client-side JavaScript |
| What it tracks | Named events you instrument | All pageloads automatically |
| PII stored | None | IP anonymized, device info |
| Bot filtering | Yes (server UA check) | Yes (client-side) |
| Real-time | No (daily buckets) | Yes |
| Bounce rate / session data | No | Yes |
| Cost | Free (your DB) | Paid subscription |
Use internal analytics for product-specific instrumentation (e.g. "how many users hit the upgrade screen") and Fathom for traffic/SEO/marketing metrics.
---
# Changelog System
# Source: https://petal.build/petal-pro/fundamentals/changelog-system
# Changelog System
The changelog system gives your app a public-facing update feed. Users can browse releases, like entries, and track what they've read. Admins manage entries through the admin DataTable UI or directly via Claude Code using the built-in MCP tools.
> **v4 note**: The context was renamed from `ChangelogUpdates` (v3) to `PetalPro.Changelog` in v4. The primary schema is now `PetalPro.Changelog.Update`.
## What's included
- Public changelog page at `/changelog` (also accessible from the sidebar)
- Per-entry slug URLs (`/changelog/:slug`) with SEO meta descriptions
- Like/unlike on individual entries (authenticated users)
- Read tracking — the sidebar notification bell shows unread count
- Optional site-wide **banner** that can be attached to any published entry
- Notification and email dispatch on publish (opt-in per entry)
- Admin DataTable at `/admin/changelog` with full Flop pagination, sorting, and filtering
- MCP tools for managing entries via Claude Code
## Schemas
### `PetalPro.Changelog.Update`
The primary content record:
| Field | Type | Notes |
|-------|------|-------|
| `title` | string | Required. Auto-generates `slug`. |
| `slug` | string | URL-safe, unique, auto-derived from title. |
| `summary` | string | Short teaser shown in lists and meta descriptions. |
| `content` | string | Full content as EditorJS JSON. |
| `status` | enum | `:draft`, `:published`, `:archived` |
| `category` | enum | `:new`, `:improve`, `:fix` |
| `published_at` | utc_datetime | Set automatically on publish. |
| `show_banner` | boolean | Promotes this entry to the site-wide banner. |
| `banner_style` | enum | `:info`, `:success`, `:warning`, `:error` |
| `banner_cta_text` | string | Button label for the banner. |
| `banner_cta_url` | string | Button destination for the banner. |
| `banner_expires_at` | utc_datetime | Auto-hides banner after this time. |
| `send_notification` | boolean | Send in-app notification to all users on publish. |
| `send_email` | boolean | Send email to all users on publish. |
### `PetalPro.Changelog.Like`
Tracks which users liked which updates. Unique constraint on `[:changelog_update_id, :user_id]`.
### `PetalPro.Changelog.Read`
Tracks whether a user has read an update and how (`read_via`: `:changelog_page` or `:banner`). Used to power the unread count in the notification bell.
## Context module
`PetalPro.Changelog` is the public interface. Key functions:
```elixir
# Admin: list all updates (optional status filter)
Changelog.list_changelog_updates()
Changelog.list_changelog_updates(%{status: :draft})
# Public: list published updates with like counts and user like status
Changelog.list_public_changelog_updates(%{}, current_user)
# Fetch by slug (raises if not found or not published)
Changelog.get_published_changelog_update_by_slug!("my-update-slug")
# CRUD
Changelog.create_changelog_update(attrs)
Changelog.update_changelog_update(update, attrs)
Changelog.publish_changelog_update(update, published_by_user)
Changelog.unpublish_changelog_update(update)
Changelog.archive_changelog_update(update)
Changelog.delete_changelog_update(update)
# Read tracking
Changelog.mark_as_read(update, user, :changelog_page)
Changelog.get_unread_count(user)
# Likes
Changelog.toggle_like(update, user)
Changelog.get_like_count(update)
# Banner
Changelog.get_active_banner()
```
Publishing dispatches an Oban job (`ChangelogNotificationWorker`) if `send_notification` or `send_email` is set on the entry. Only one banner can be active at a time — publishing a second banner-enabled entry returns a validation error.
## Admin DataTable
`PetalProWeb.AdminChangelogLive` at `/admin/changelog` uses the `DataTable` component backed by Flop. Features:
- Sort by title, category, status, published date
- Filter by status
- Inline publish/unpublish/archive/delete actions
- Per-entry read count and like count stats
- Link to the public entry
## Public page and SEO
The public changelog lives at `/changelog`. Individual entries are at `/changelog/:slug`. The `summary` field populates the `` tag for each entry page, so write it like you mean it — search engines and unfurl previews both use it.
## MCP tools (Claude Code integration)
Petal Pro exposes three changelog tools via its admin MCP server at `POST /api/mcp`. These are available to Claude Code and Claude Desktop when connected with a bearer token.
### `list_changelog_updates`
Lists updates with optional status filter and limit.
```
list_changelog_updates(status: "draft", limit: 10)
```
Returns: `id`, `title`, `slug`, `status`, `category`, `show_banner`, `published_at`, `author`.
### `manage_changelog`
Performs a single action on an existing update identified by slug or UUID.
```
manage_changelog(identifier: "my-update-slug", action: "get")
manage_changelog(identifier: "my-update-slug", action: "publish", publisher_email: "admin@example.com")
manage_changelog(identifier: "my-update-slug", action: "unpublish")
manage_changelog(identifier: "my-update-slug", action: "archive")
manage_changelog(identifier: "my-update-slug", action: "delete")
manage_changelog(identifier: "my-update-slug", action: "update", title: "New title", category: "fix")
```
The `get` action returns full details including read count, read percentage, and like count. The `publish` action requires `publisher_email` — it must belong to an admin user.
### `create_changelog_update`
Creates a new entry in `:draft` status.
```
create_changelog_update(
title: "Better dark mode",
content: "{\"blocks\":[{\"type\":\"paragraph\",\"data\":{\"text\":\"We overhauled dark mode...\"}}]}",
category: "improve",
summary: "Dark mode now respects system preference on first visit.",
send_notification: true
)
```
Content must be an EditorJS JSON string. `category` defaults to `"new"`. `author_email` defaults to the first admin user if omitted.
### Creating a changelog entry from Claude Code
```
> Use the create_changelog_update tool to draft a changelog entry titled "Faster search"
in the "improve" category. Summary: "Search results now load 3x faster."
Then publish it using my email: matt@example.com
```
Claude will call `create_changelog_update` to draft the entry, then `manage_changelog` with `action: "publish"` to go live. The whole flow takes seconds without touching the admin UI.
---
# Deployment
# Source: https://petal.build/petal-pro/fundamentals/deployment
# Deployment
How to get your app running in production
## Before deploy
Before deployment, we've found an issue in production with the new content security policy headers. Basically, you need to add `wss://...` and your domain to the `:content_security_policy` `default_src` array, like below.
config.exs
```css
config :my_app, :content_security_policy, %{
default_src: [
....
"wss://yourdomain.com",
"wss://yourdomain.com/live/websocket"
]
}
```
Read more:
- [Petal Pro Github issue](https://github.com/petalframework/petal_pro/issues/46)
- [Blog post about content security policy in Phoenix apps](https://furlough.merecomplexities.com/elixir/phoenix/security/2021/02/26/content-security-policy-configuration-in-phoenix.html)
### Removing CSP
If you don't care about content security policies, you can remove this config and then in `router.exs` change:
```elixir
# Change this:
plug(:put_secure_browser_headers, %{
"content-security-policy" =>
ContentSecurityPolicy.serialize(
struct(ContentSecurityPolicy.Policy, PetalPro.config(:content_security_policy))
)
})
# To this:
plug :put_secure_browser_headers
```
### Faker dependency and the Landing Page
If you haven't changed the landing page yet, there will be references to [Faker](https://hexdocs.pm/faker). If you don't remove references to Faker, you'll need to find this line in `mix.exs`:
```elixir
defp deps do
[
{:faker, "~> 0.17", only: [:test, :dev]},
]
```
And change it to this:
```elixir
defp deps do
[
{:faker, "~> 0.17"},
]
```
## A word on environment variables
In production, Petal Pro requires the following environment variables to be set:
- `SECRET_KEY_BASE`
- `DATABASE_URL`
- `PHX_HOST`
- `PORT`
- `RESEND_API_KEY` — transactional email via Resend
Optional but recommended:
- `SENTRY_DSN` — error tracking auto-activates when set
- `MCP_ADMIN_TOKEN` — bearer token for the admin MCP API
If you're deploying to Fly.io, the core variables should be automatically taken care of. However, if you're testing `prod` on your dev machine or using a different deployment mechanism, you'll need to set these yourself.
`SECRET_KEY_BASE` and `DATABASE_URL` are secrets and need to be protected. Follow best practices for managing secrets on the platform you are deploying to.
## Docker
Petal Pro v4 ships a production-ready `Dockerfile` in the repo root. It uses a multi-stage build: a builder stage compiles the Elixir release (including npm asset pipeline), and a lean Debian runner stage contains only the compiled release.
### Build the image
```shell
docker build -t myapp .
```
### Run locally
```shell
docker run -p 4000:4000 \
-e SECRET_KEY_BASE="your_secret_key_base" \
-e DATABASE_URL="ecto://user:pass@host/db" \
-e PHX_HOST="localhost" \
-e PORT="4000" \
-e RESEND_API_KEY="re_..." \
myapp
```
### Key runtime environment variables
| Variable | Required | Notes |
|---|---|---|
| `SECRET_KEY_BASE` | Yes | Generate with `mix phx.gen.secret` |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `PHX_HOST` | Yes | Your domain (no `https://`) |
| `PORT` | Yes | Usually `4000` |
| `RESEND_API_KEY` | Yes | Transactional email |
| `SENTRY_DSN` | No | Error tracking |
| `MCP_ADMIN_TOKEN` | No | Admin AI tool access |
The Dockerfile targets Elixir 1.17.2 / OTP 27.0.1 on Debian Bookworm slim. To change versions, update the `ARG` lines at the top of the file.
## Deployment with Fly.io
To deploy to Fly.io, use the following guide:
[Deploy to Fly.io](/petal-pro/guides/deploy-to-fly.io)
## Error Tracking
Petal Pro uses [Sentry](https://sentry.io) for error tracking. It auto-activates when the `SENTRY_DSN` environment variable is set — no code changes required.
### How It Works
Sentry is configured in `config/runtime.exs`:
```elixir
if sentry_dsn = System.get_env("SENTRY_DSN") do
config :sentry, dsn: sentry_dsn
end
```
A Sentry logger handler is attached at application startup, which captures errors logged via Elixir's `Logger`:
```elixir
:logger.add_handler(:sentry_handler, Sentry.LoggerHandler, %{
config: %{metadata: [:file, :line]}
})
```
In development, if `SENTRY_DSN` is not set, errors go to your local logs and nothing is sent to Sentry. Set the DSN in dev if you want to test the integration.
### Setup
#### 1. Create a Sentry project
1. Sign up or log in at [sentry.io](https://sentry.io)
2. Create a new project — select **Elixir** as the platform
3. Copy the DSN from the project settings (looks like `https://abc123@o123456.ingest.sentry.io/789`)
#### 2. Set the DSN in production
**Fly.io:**
```shell
fly secrets set SENTRY_DSN=https://your-dsn@sentry.io/project-id
```
**Other platforms:** Set `SENTRY_DSN` as an environment variable in your deployment configuration.
That's it. Deploy and Sentry will start receiving errors.
### What Gets Captured Automatically
- Unhandled exceptions in controllers and LiveViews
- LiveView crash reports
- Any `Logger.error/2` or `Logger.warning/2` calls
- Errors from Oban workers
- Anything that bubbles up through the OTP supervision tree
### Adding Custom Context
#### Setting user context
Call this when a user authenticates to attach their identity to all subsequent errors in that process:
```elixir
Sentry.Context.set_user_context(%{
id: user.id,
email: user.email,
name: user.name
})
```
A good place to do this is in your auth hooks or controller plugs.
#### Capturing custom messages
For non-exception events you want to track:
```elixir
Sentry.capture_message("Billing webhook received unexpected event type",
level: :warning,
extra: %{event_type: event_type, payload: payload}
)
```
#### Capturing exceptions manually
```elixir
try do
risky_operation()
rescue
e ->
Sentry.capture_exception(e, stacktrace: __STACKTRACE__, extra: %{context: "my_function"})
reraise e, __STACKTRACE__
end
```
### Verifying It Works
The easiest test is to trigger an error from IEx on your production instance and confirm it appears in Sentry within a minute or two.
From a Fly.io console:
```shell
fly ssh console --app your-app-name
/app/bin/your_app remote
```
Then in IEx:
```elixir
Sentry.capture_message("Test error from IEx", level: :error)
```
Check your Sentry project — the event should appear shortly.
You can also check that the DSN is loaded correctly:
```elixir
Application.get_env(:sentry, :dsn)
```
If this returns `nil`, `SENTRY_DSN` isn't set in the environment.
### Environment Behavior
| Environment | `SENTRY_DSN` set? | Behavior |
|-------------|-------------------|----------|
| Production | Yes | Errors sent to Sentry |
| Production | No | Errors logged only (misconfiguration — set the DSN) |
| Dev/Test | No | Errors logged locally, nothing sent to Sentry |
| Dev/Test | Yes | Errors sent to Sentry (useful for testing the integration) |
## Rate Limiting
Petal Pro uses [Hammer](https://github.com/ExHammer/hammer) with an ETS backend for rate limiting. It's applied to auth endpoints out of the box and is straightforward to add anywhere else.
### What's Rate Limited by Default
| Endpoint | Key | Limit |
|----------|-----|-------|
| API sign-in (`POST /api/session`) | IP address | 5 requests per minute |
| API registration (`POST /api/register`) | IP address | 3 requests per hour |
Both use a plug that runs before the action and returns a 429 if the limit is exceeded.
### How It Works
The rate limiter is defined in `lib/petal_pro/rate_limiter.ex`:
```elixir
defmodule PetalPro.RateLimiter do
use Hammer, backend: :ets
end
```
It starts as part of the application supervision tree. All state lives in ETS — no Redis or external dependency needed.
Call `hit/3` with a key, time window (in milliseconds), and request limit:
```elixir
PetalPro.RateLimiter.hit("some_key", :timer.minutes(1), 10)
# => {:allow, count} | {:deny, retry_after_ms}
```
### Adding Rate Limiting to a Controller
Use a plug that calls `hit/3` and halts on denial:
```elixir
defmodule MyAppWeb.MyController do
use MyAppWeb, :controller
plug :rate_limit when action in [:create]
defp rate_limit(conn, _opts) do
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
case PetalPro.RateLimiter.hit("my_action:#{ip}", to_timeout(minute: 1), 10) do
{:allow, _count} ->
conn
{:deny, _retry_after} ->
conn
|> put_status(:too_many_requests)
|> json(%{errors: %{detail: "Too many requests"}})
|> halt()
end
end
end
```
#### Key naming
Use a descriptive prefix with a per-user or per-IP discriminator:
```elixir
# Per IP
"sign_in:#{ip}"
# Per user
"api:#{user_id}"
# Per email (good for password reset)
"reset:#{email}"
```
### Adding Rate Limiting to a LiveView
LiveViews don't have plugs, so check the limit inside the event handler:
```elixir
def handle_event("submit", _params, socket) do
user_id = socket.assigns.current_user.id
case PetalPro.RateLimiter.hit("my_form:#{user_id}", to_timeout(minute: 1), 5) do
{:allow, _count} ->
# proceed
{:noreply, do_the_thing(socket)}
{:deny, _retry_after} ->
{:noreply, put_flash(socket, :error, "You're doing that too fast. Try again in a minute.")}
end
end
```
### Time Windows
Use `to_timeout/1` (available in Phoenix controllers) or `:timer` functions:
```elixir
to_timeout(minute: 1) # 60_000 ms
to_timeout(hour: 1) # 3_600_000 ms
:timer.minutes(1) # same as above, works anywhere
:timer.hours(1)
```
### Response When Rate Limited
Controllers return HTTP 429 with a JSON body:
```json
{"errors": {"detail": "Too many requests"}}
```
LiveViews should use `put_flash/3` to show an error message — there's no HTTP status code to set.
### Clearing a Rate Limit in Dev
Restart your dev server — ETS is in-memory and doesn't persist across restarts. That's the fastest way to clear all limits.
If you need to clear a specific key without restarting, call `delete/1` from IEx:
```elixir
PetalPro.RateLimiter.delete("sign_in:127.0.0.1")
```
### Configuration
There's no central config file for limits — each call site defines its own window and limit inline. This is intentional: different actions have different risk profiles, and collocating the limit with the code that enforces it makes it easier to reason about.
To change a limit, find the `RateLimiter.hit/3` call and update the second (window) or third (max requests) argument.
---
# Background Tasks & Jobs
# Source: https://petal.build/petal-pro/fundamentals/background-tasks-and-jobs
# Background Tasks and Jobs
## Once-off background tasks
When you just want to run some code in a background task, use `PetalPro.BackgroundTask.run/1`:
```elixir
PetalPro.BackgroundTask.run(fn ->
do_some_time_intensive_stuff()
end)
```
This is useful for speeding up responses in an HTTP request or LiveView action — delegate anything that takes time to a background task (e.g. sending emails, posting to Slack, creating audit logs). Since we want to respond to a user request as soon as possible, you run these non-essential tasks in the background and it won't hold up the response.
## Background jobs with Oban
A background job is something you want to run outside of a normal HTTP request/response cycle — things like sending weekly emails or processing images. We use [Oban](https://github.com/sorentwo/oban) for this.
A background job is performed by a "worker". Each worker gets its own file. Petal Pro comes with an example worker you can duplicate:
```elixir
defmodule PetalPro.Workers.ExampleWorker do
@moduledoc """
Example of how to do async work with Oban.
Run with:
Oban.insert(PetalPro.Workers.ExampleWorker.new(%{}))
"""
use Oban.Worker, queue: :default
require Logger
@impl Oban.Worker
def perform(%Oban.Job{} = _job) do
today = Timex.now() |> Timex.to_date()
Logger.info("ExampleWorker: Today is #{today}")
:ok
end
end
```
You can run this worker by inserting a job into the database:
```elixir
Oban.insert(PetalPro.Workers.ExampleWorker.new(%{}))
```
Once in the database, Oban will try running the task and retry it if it fails. You can see any failed jobs by checking the database:
You can inspect job status directly in the `oban_jobs` database table.
In dev mode, you can use Oban Web:
```
http://localhost:4000/admin/oban
```
Jobs that have been completed are deleted every 24 hours so your database won't swell over time.
### Cron jobs
You can tell your workers to run at certain times with Oban's cron functionality. See the examples in `config.exs`:
```elixir
config :petal_pro, Oban,
repo: PetalPro.Repo,
queues: [default: 5],
plugins: [
{Oban.Plugins.Pruner, max_age: (3600 * 24)},
{Oban.Plugins.Cron,
crontab: [
{"@daily", PetalPro.Workers.ExampleWorker}
# {"* * * * *", PetalPro.EveryMinuteWorker},
# {"0 * * * *", PetalPro.EveryHourWorker},
# {"0 */6 * * *", PetalPro.EverySixHoursWorker},
# {"0 0 * * SUN", PetalPro.EverySundayWorker},
# More examples: https://crontab.guru/examples.html
]}
]
```
## Daily Slack digest worker
Petal Pro v4 ships `PetalPro.Workers.DailySlackDigestWorker` — an Oban cron job that sends a summary of yesterday's activity to a Slack channel. It pulls new user signups, audit log action counts, and top page views, then formats them as a single Slack message.
### Enabling the digest
Add the worker to the cron schedule in `config.exs`:
```elixir
{Oban.Plugins.Cron,
crontab: [
{"0 13 * * *", PetalPro.Workers.DailySlackDigestWorker} # 8am ET / 1pm UTC
]}
```
### Slack configuration
The digest uses `PetalPro.Slack` to post the message. You need two environment variables:
```shell
# OAuth token from your Slack app's "OAuth and Permissions" page
fly secrets set SLACK_OAUTH_TOKEN="xoxb-..."
# The ID of the Slack channel to post to
fly secrets set SLACK_CHANNEL_ID="C0123456789"
```
To find your channel ID: run `PetalPro.Slack.read_channels()` in IEX — it returns a list of `[id, name]` pairs. If the digest is not configured (no `SLACK_CHANNEL_ID`), it silently skips sending and logs a message instead.
To test locally:
```elixir
PetalPro.Slack.message("Hello from Petal Pro!")
```
## Slow query telemetry (dev only)
In development, Petal Pro logs any database queries that exceed a configurable time threshold. This makes it easy to spot N+1 queries and unexpectedly slow operations without any additional tooling.
The threshold is set in `config/dev.exs`:
```elixir
# Log queries slower than this many milliseconds (dev only)
config :petal_pro, :dev_query_threshold_ms, 100
```
Queries that exceed the threshold are logged to the console with their duration and SQL. To tighten the threshold and catch more queries, lower the value:
```elixir
config :petal_pro, :dev_query_threshold_ms, 50
```
Set it to `0` to log every query. This config has no effect in production.
---
# Seeding
# Source: https://petal.build/petal-pro/fundamentals/seeding
# Seeding
Seeding means populating the database with records. We do this in development to show what the web app looks like with data.
We do this in one script file: `seeds.exs`
seeds.exs
```elixir
aliasPetalPro.Accounts.User
aliasPetalPro.Accounts.UserSeeder
aliasPetalPro.Accounts.UserToken
aliasPetalPro.Accounts.UserTOTP
aliasPetalPro.Files.File
aliasPetalPro.Files.FileSeeder
aliasPetalPro.Logs.Log
aliasPetalPro.Orgs.Invitation
aliasPetalPro.Orgs.Membership
aliasPetalPro.Orgs.Org
aliasPetalPro.Orgs.OrgSeeder
aliasPetalPro.Posts.Post
aliasPetalPro.Posts.PostSeeder
ifMix.env()==:devdo
PetalPro.Repo.delete_all(Post)
PetalPro.Repo.delete_all(File)
PetalPro.Repo.delete_all(Log)
PetalPro.Repo.delete_all(UserTOTP)
PetalPro.Repo.delete_all(Invitation)
PetalPro.Repo.delete_all(Membership)
PetalPro.Repo.delete_all(Org)
PetalPro.Repo.delete_all(UserToken)
PetalPro.Repo.delete_all(User)
admin =UserSeeder.admin()
normal_user =
UserSeeder.normal_user(%{
email:"[email protected]",
name:"Sarah Cunningham",
password:"password",
confirmed_at:Timex.to_naive_datetime(DateTime.utc_now())
})
UserSeeder.fake_subscription(normal_user)
org =OrgSeeder.random_org(admin)
PetalPro.Orgs.create_invitation(org, %{email: normal_user.email})
UserSeeder.random_users(20)
FileSeeder.create_files(admin)
PostSeeder.create_posts(admin)
end
```
We use the faker library for randomizing data:
```elixir
def random_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
name: Faker.Person.En.first_name() <> " " <> Faker.Person.En.last_name(),
email: unique_user_email()
})
end
```
Seeds should be regularly updated with any new tables you create. This allows you to better test and develop your app.
---
# Testing
# Source: https://petal.build/petal-pro/fundamentals/testing
# Testing
## Getting tests to work
Run `mix test` to run the tests.
Petal Pro uses [Mimic](https://hex.pm/packages/mimic) for mocking in tests (not Mox). Check existing test files for examples of how to mock external services like Stripe.
---
# REST API
# Source: https://petal.build/petal-pro/fundamentals/rest-api
# REST API
Petal Pro comes with a pre-built REST API. Though it is limited in scope, the API has been created so that it is easy to extend
The REST API is a sample API that has been created for user management. For example, you could use the API with a mobile application to register a user, sign them in and update their profile.
Though the API is provided as an example, it can be easily extended or updated. The key to this extensibility is the selection of technologies:
- The Phoenix Framework provides [first-class support](https://hexdocs.pm/phoenix/json_and_apis.html) for creating APIs
- `phx.gen.auth` is used as a basis for [API Authentication](https://hexdocs.pm/phoenix/api_authentication.html)
- Open API support via [Open API Spex](https://hexdocs.pm/open_api_spex)
## Taking Advantage of the Phoenix Framework
With the Phoenix Framework, you can use the following command to generate a JSON API:
```elixir
mixphx.gen.jsonUrlsUrlurlslink:stringtitle:string
```
This creates a set of files that take care of the database schema, data access control, the JSON for the output and testing. All you are required to do is add the suggested route to the `/api` scope. This provides a complete/comprehensive starting point for your own work.
ℹ️ Information: The Phoenix generator will put your controller under:
``elixir
/lib/petal_pro_web/controllers
`
This isn't a problem, but you might like to move it to where the other API controllers are:
`elixir
/lib/petal_pro_api/controllers
``
The same is true for the test that's generated. See [Directory Structure](/petal-pro/fundamentals/rest-api#directory-structure) (below) for more details
## A Word on Authentication
With regards to securing a HTML web page, authentication is stateful. That is, authentication involves user interaction (e.g. via a HTML form) and state is maintained via methods such as maintaining session state on the server and storing essential data in a browser cookie.
API authentication, on the other hand, is usually stateless and does not involve direct interaction with the end user. Authentication is usually about authorising a client (application or server) - rather than an individual. This process is handled programmatically between systems and enough data must be sent with each call to achieve authentication. For example, a mobile app may have a client that communicates with the API server.
## The Petal Pro Sample API Token
Following the recommendations in the Phoenix Framework [API Authentication](https://hexdocs.pm/phoenix/api_authentication.html) documentation, client devices/services can access the API if they provide a valid API token.
However, the API has been created so each user can generate their own token. The "Register", "Send Instructions" and "Sign In" API calls do not require an API token. If you sign In successfully, an API token is supplied.
You can use the token from the "Sign In" call for the other API calls. API calls protected by a token are locked down to that particular user. For example, even if you're an admin user, you can't view the profile of another user.
As an alternative, you could create a page in the website where users can generate their API token. You could use this mechanism to tie a client application to an account. However, this would require a change in logic for the API calls (i.e. you would need to allow this user to view/change any user).
## Support for Open API
Support for Open API has been added via [Open API Spex](https://hexdocs.pm/open_api_spex). Existing API calls are decorated with meta data that provides enough information to generate an Open API spec. For example, here is a cut down version of the "Sign In" call:
session_controller.ex
```elixir
operation :create,
summary: "Authenticate user",
description: "Authenticate user and generate bearer token",
request_body: {"User credentials", "application/json", Schemas.UserCredentials},
responses: [
ok: {"Authenticated response", "application/json", Schemas.AuthResponse},
unauthorized: %Reference{"$ref": "#/components/responses/unauthorised"}
]
def create(conn, %{"email" => email, "password" => password}) do
# ...
end
```
The `operation` macro allows you to keep your Open API spec with your API call - yet keep them logically separated.
The general configuration for the Open API spec can be found in `/lib/petal_pro_api/api_spec`. It's used to configure things like the title, version or type of authorisation used in the API. It also defines standard responses. For example, it defines the `unauthorised` response that the above code snippet is referring to.
One of the benefits of the Open API spec is that it can be used to generate a UI for testing. To see the SwaggerUI, navigate to:
```css
http://localhost:4000/dev/swaggerui
```
The spec itself can be found at:
```css
http://localhost:4000/api/openapi
```
You can use this with 3rd party tools to generate code or documentation for an API client.
Check out the [documentation](https://hexdocs.pm/open_api_spex) for more details on how to configure your API code with Open API Spex.
## Directory Structure
The Petal Pro API has been separated into it's own directory structure. This is relevant to the `/lib` and `/test` directories:
```elixir
├── lib
│ ├── petal_pro
│ ├── petal_pro_api
| | ├── controllers
| | ├── api_spec.ex
| | ├── routes.ex
| | ├── schemas.ex
│ ├── petal_pro_web
├── test
│ ├── petal_pro
│ ├── petal_pro_api
│ ├── petal_pro_web
```
For the `/lib/petal_pro_api` directory:
- `controllers` is where the API methods are kept
- `api_spec.ex` is the starting point for Open API Spex (see above)
- `routes.ex` is where the API routes are defined. This is included in `/lib/petal_pro_web/router.ex`
- `schemas.ex` are the data models that are used by the Open API Spex definition
## Creating you're own API
To get your hands dirty with the sample API, see the following:
[Creating Your Own API](/petal-pro/guides/creating-your-own-api)
---
# GDPR
# Source: https://petal.build/petal-pro/fundamentals/gdpr
# GDPR
Petal Pro ships with a built-in GDPR compliance system covering two user rights: **the right to data portability** (export) and **the right to erasure** (account deletion). Both are exposed to users through Settings > Your Data and processed by `PetalPro.Accounts.GDPR`.
## Enabling GDPR mode
The system is gated behind the `:gdpr_mode` config key. When disabled, the Settings > Your Data page is hidden from users.
```elixir
# config/config.exs
config :petal_pro, :gdpr_mode, true
```
Check programmatically with:
```elixir
PetalPro.gdpr_mode()
# => true
```
## Data export
### What users see
Users visit **Settings > Your Data** (`/app/users/your-data`) and click "Download my data". The browser receives a JSON file generated on the fly.
### What's included
`PetalPro.Accounts.GDPR.export_user_data/1` assembles a single map with these top-level keys:
| Key | Contents |
|-----|----------|
| `exported_at` | ISO 8601 timestamp |
| `user` | Name, email, avatar, role, onboarding status, timestamps |
| `organizations` | Org name, slug, membership role, join date |
| `notifications` | Up to 1 000 most recent (truncated flag if capped) |
| `notification_preferences` | Per-type in-app/email toggles |
| `billing` | Masked provider customer ID, subscription history |
| `posts` | Post titles, slugs, categories, publish dates |
| `files` | File names and URLs |
| `feedback` | User's own submitted feedback items |
| `ai_usage` | AI call log entries — provider, model, token counts, cost |
| `activity_log` | Audit log entries attributed to the user |
| `changelog_interactions` | Changelog reads and likes |
Security-sensitive data is deliberately excluded: password hashes, auth tokens, TOTP secrets, and PINs are never exported. Billing provider IDs (Stripe customer IDs) are masked to the last four characters.
### Generating the JSON
```elixir
user = Repo.get!(User, user_id)
# Returns a map
data = PetalPro.Accounts.GDPR.export_user_data(user)
# Returns a pretty-printed JSON string
json = PetalPro.Accounts.GDPR.generate_export_json(user)
```
### Adding custom data to exports
When you add a new schema that associates data with a user, update `export_user_data/1` to include it. The function builds a map — just add a new key:
```elixir
def export_user_data(%User{} = user) do
user = Repo.preload(user, [:memberships, :customer])
%{
exported_at: DateTime.to_iso8601(DateTime.utc_now()),
user: export_user_profile(user),
# ... existing keys ...
survey_responses: export_survey_responses(user) # your addition
}
end
defp export_survey_responses(user) do
MyApp.Surveys.SurveyResponse
|> where(user_id: ^user.id)
|> Repo.all()
|> Enum.map(fn r ->
%{
question: r.question,
answer: r.answer,
submitted_at: r.inserted_at
}
end)
end
```
## Account deletion
### How it works
Deletion is a two-step process:
1. The user clicks "Delete my account" in Settings > Your Data and confirms.
2. `PetalPro.Accounts.GDPR.can_delete_user?/1` runs a guard check — a user cannot be deleted if they are the **sole admin** of any organization.
3. If the check passes, an Oban job (`GDPRDeletionWorker`) is enqueued. The user is informed their deletion is scheduled and the session ends shortly after.
4. The worker calls `execute_data_deletion/1` which runs inside a single database transaction.
### What `execute_data_deletion/1` does
The deletion cascade runs in this order:
1. **Logs the deletion** — audit record created before anything is anonymized
2. **Deletes AI call logs** — contain sensitive prompt/response data
3. **Deletes file records** — storage object cleanup is a separate concern
4. **Anonymizes feedback** — `user_id` set to nil, content kept
5. **Anonymizes posts** — `author_id` set to nil, posts shown as "Deleted User"
6. **Deletes notifications** — recipient notifications deleted; sender references nullified
7. **Deletes notification preferences**
8. **Deletes changelog interactions** — reads and likes
9. **Deletes auth data** — tokens, PINs, TOTP configs
10. **Removes org memberships and invitations**
11. **Anonymizes audit logs** — `user_id` nullified, metadata cleared (record kept for compliance)
12. **Anonymizes billing customer** — email replaced, `user_id` cleared (record kept for tax/legal)
13. **Soft-deletes the user** — name set to "Deleted User", email replaced with `deleted-{uuid}@deleted.local`, `deleted_at` timestamp set
14. **Removes marketing email contact** — Resend contact deleted outside the transaction; failure is logged but does not roll back the DB deletion
### Adding custom deletion logic
Add cleanup for your new schema inside the transaction in `execute_data_deletion/1`. Choose the right strategy:
- **Delete the records** when they contain personal data that serves no compliance purpose.
- **Nullify the foreign key** when you want to keep the record but disassociate it from the user.
```elixir
def execute_data_deletion(%User{} = user) do
result =
Repo.transaction(fn ->
# ... existing steps ...
# Delete records that belong to this user
MyApp.Surveys.SurveyResponse
|> where(user_id: ^user.id)
|> Repo.delete_all()
# Or anonymize by nullifying the user reference
MyApp.Comments.Comment
|> where(author_id: ^user.id)
|> Repo.update_all(set: [author_id: nil])
# ... rest of cascade ...
end)
result
end
```
> The CLAUDE.md for `petal_pro` explicitly calls this out as a required step whenever you add a new user-associated schema: update both `export_user_data/1` and `execute_data_deletion/1`.
---
# Web App From Start to Finish
# Source: https://petal.build/petal-pro/guides/creating-a-web-app-from-start-to-finish
# Creating a web app from start to finish
Matt here. Let's build a complete web app from scratch — branding, feature building, logging, emails, notifications, tests, and deployment.
Before we get into it: this is not a v3-style tutorial where I walk you through every file change. Petal Pro v4 ships with CLAUDE.md files throughout the project that give Claude Code full context about the architecture, conventions, and patterns. Your job is to describe what you want. Claude builds it. You review, understand, and steer. That's the workflow we'll follow.
ℹ️ Note: This tutorial was written using Petal Pro v4
### What you'll learn
We're building "Financy" — a personal finance tracker. Along the way:
- **Branding** — Logo generation and color theming with one Claude command
- **Feature building** — How to describe a feature and let Claude build it
- **Layouts & menus** — Adding pages to the sidebar
- **Logging** — Track user actions with the built-in logging system
- **Emails** — Monthly summary emails scheduled with Oban
- **Notifications** — In-app and email alerts
- **Testing** — Fixing and extending generated tests
- **Deployment** — Ship to Fly.io
## Getting up and running
### What you need
- Access to Petal Pro ([petal.build](https://petal.build))
- Elixir & Erlang installed — see our [installation guide](/petal-pro/fundamentals/installation)
- PostgreSQL running — [Postgres.app](https://postgresapp.com) for Mac, or use the included `docker-compose.yaml`
- For deployment (optional): a [Fly.io](https://fly.io/) account and an email provider (Resend is the easiest — set `MAIL_PROVIDER=resend`)
### Download
Head to the [projects page](https://petal.build/pro/projects), create a project, and download the latest version. Unzip it.
### Rename
```zsh
mv petal_pro_4.0.0 financy
cd financy
```
You don't need to rename the internal module names — keeping them as `PetalPro` makes it easier to pull in future Petal Pro updates.
### Get it running
```zsh
mix setup
```
Then open Claude Code and ask it to start the server:
```
start the server
```
Claude reads the CLAUDE.md and knows exactly what to do:
```
⏺ Bash(tmux kill-session -t petal_pro 2>/dev/null; tmux new-session -d -s petal_pro "iex -S mix phx.server")
⎿ (No output)
⏺ Server's up in the petal_pro tmux session. Give it a few seconds to compile,
then hit localhost:4000. Attach with tmux attach -t petal_pro if you need IEx.
```

Claude knew to use tmux — it read the CLAUDE.md
Navigate to `http://localhost:4000`. You'll see the default Petal Pro landing page — blue buttons, Petal logo. Let's brand it before we go any further.
## Adding your brand
Petal Pro ships with a `/generate-brand` command that generates all your logo files from a short description in `docs/branding.md`. Open that file and fill it in:
docs/branding.md
```markdown
## App name
Financy
## Tagline (optional, used in OG image)
Personal assets visualised
## Primary brand color (hex)
#10b981
## Icon style
A dollar sign done in a pixelated way.
```
Then in Claude Code:
```
/generate-brand
```
Claude designs an icon and generates all six files (`logo_dark.svg`, `logo_light.svg`, `logo_icon_*.svg`, `favicon.svg`, `open-graph.png`) into `priv/static/images/`. Don't like it? Tweak "Icon style" and run it again.
Also update `config.exs` with your app details (search for `SETUP_TODO`):
config.exs
```elixir
config :petal_pro,
app_name: "Financy",
business_name: "Financy Pty Ltd",
support_email: "support@financy.app",
mailer_default_from_name: "Financy",
mailer_default_from_email: "support@financy.app",
logo_url_for_emails: "https://yourcdn.com/financy-logo.png",
seo_description: "Personal finance management — track income, expenses, and build toward your goals"
```
And swap the colors in `assets/css/colors.css` — replace `blue` with `emerald` and `pink` with `slate`. Emerald makes sense for a finance app.
Restart the server, reload the browser:

Landing page after branding — Financy pixel-dollar logo, emerald CTA
ℹ️ Email logo: The logo in emails must be a public URL — email clients can't load local files. Once you're happy with the logo, upload logo_for_emails.png to Cloudinary, S3, or your CDN and paste the URL into logo_url_for_emails in config.exs.
The other SETUP_TODOs cover license and privacy policy content — skip those for now, they're not needed until you go live.
## Explore what's included
Before building, take five minutes to understand what Petal Pro already gives you. Ask Claude:
```
give me a quick overview of what's included in this project
```
```
⏺ Based on the CLAUDE.md files, here's what's already built:
Auth — Email/password with confirmation, passwordless PIN login, Google and GitHub
OAuth, TOTP 2FA. All methods can coexist on the same account.
Admin panel — /admin with user management, org management, real-time activity
logs, email template previewer, database schema visualizer, and route explorer.
Multi-tenancy — Full org system with invitations, roles (owner/admin/member),
and slug-based routing under /app/org/:org_slug/.
User settings — Profile, email change, password, 2FA, notification preferences,
GDPR data export and account deletion.
Background jobs — Oban with default and billing queues. Built-in daily workers
for GDPR cleanup and purging expired invitations.
Notifications — In-app notification bell + email delivery, per-type user
preferences, real-time PubSub updates.
Billing — Full Stripe integration, configurable per user or per org.
Blog/CMS — Editor.js rich content editor, drafts, categories.
```
Sign in with `admin@example.com` / `password` and click around. A few things worth seeing:
**The admin panel** — Click your avatar → Admin. The Logs tab is where user activity ends up — you'll add to it shortly. Dev → Email templates lets you preview every transactional email. The database schema visualizer generates an interactive ER diagram of your whole schema.

Admin panel — logs, users, orgs, dev tools
**Authentication** — Before signing in, notice the auth options. Passwordless, email/password, OAuth. All built, all working.

Multiple auth methods — all configured, nothing to build
**The sidebar layout** — Once signed in you'll see the collapsible sidebar with user profile, notification bell, and navigation. This is the layout you'll wrap your own pages in.

The signed-in layout — sidebar, notification bell, user profile
**Organizations** — Full multi-tenancy. For Financy we'll scope transactions to individual users, but if you were building a team product, you'd put everything under `/app/org/:org_slug/` instead.

Orgs — multi-tenancy ready to use if you need it
**Settings** — Profile, email, password, 2FA, notification preferences, GDPR data export. None of this needs building.

User settings — fully built
That's the lay of the land. Now let's build the actual product.
## The transactions page
### Tell Claude what you want
```
Build a transaction tracker for Financy. Users should be able to log income and
expenses. Each transaction needs: a description, an amount (decimal), a type that's
either "income" or "expense", and a date. Transactions belong to the current user
and should only be visible to that user. Use the data table generator for the index.
```
Claude reads the CLAUDE.md files — they describe the conventions, the generator, how user-scoping works, how menus are structured — and gets to work:
```
⏺ Here's my plan before I start:
1. Run mix petal.gen.live Finance Transaction transactions with the right fields
2. Fix the schema — belongs_to :user instead of field :user_id, add validations
3. Add has_many :transactions to the User schema
4. Scope the index query to the current user via Ecto.assoc
5. Inject user_id on create (never from the form)
6. Swap the type field to a select dropdown (income/expense)
7. Default the date to today
8. Add routes under the /app scope
9. Wrap pages in the sidebar layout
10. Add the :transactions menu item and update home_path
Running the generator...
⏺ Bash(mix petal.gen.live Finance Transaction transactions description:string amount:decimal type:string date:date user_id:references:users --data-table)
⎿ * creating lib/petal_pro_web/live/transaction_live/...
* creating lib/petal_pro/finance/transaction.ex
* creating priv/repo/migrations/20260503_create_transactions.exs
⏺ Bash(mix ecto.migrate)
⎿ Migrated 20260503_create_transactions in 0.0s
⏺ Write(lib/petal_pro/finance/transaction.ex) — fixed schema, belongs_to, validations
⏺ Edit(lib/petal_pro/accounts/user.ex) — added has_many :transactions
⏺ Edit(lib/petal_pro_web/live/transaction_live/index.ex) — scoped to current_user
⏺ Edit(lib/petal_pro_web/live/transaction_live/form_component.ex) — user_id injection
⏺ Edit(lib/petal_pro_web/live/transaction_live/form_component.html.heex) — select dropdown
⏺ Edit(lib/petal_pro_web/router.ex) — added routes under /app
⏺ Edit(lib/petal_pro_web/live/transaction_live/index.html.heex) — sidebar layout, fixed paths
⏺ Edit(lib/petal_pro_web/live/transaction_live/show.html.heex) — sidebar layout, fixed paths
⏺ Edit(lib/petal_pro_web/menus.ex) — added :transactions get_link, updated sidebar_menu_items
⏺ Edit(lib/petal_pro_web/helpers.ex) — updated home_path to /app/transactions
⏺ Done. Navigate to http://localhost:4000/app/transactions.
```
A few of those decisions are worth understanding:
**`belongs_to :user` not `field :user_id`** — The generator creates `field :user_id, :binary_id`. That works but doesn't set up the Ecto association. `belongs_to` lets you use `Ecto.assoc/2` for scoped queries and preload the user efficiently.
**`Ecto.assoc(current_user, :transactions)`** — This is how the index query stays scoped to the signed-in user. It builds the query starting from the user association, not from all transactions.
**`user_id` is injected, not cast** — The form never exposes `user_id` as a field. It's added programmatically on create: `Map.put(params, "user_id", current_user.id)`. Never let the client dictate ownership.
### See it working
Let's seed some test data. Claude has access to the running IEx session via the tmux session that was started earlier:
```
Add some test transactions via IEx so we can see the table working
```
```
⏺ I'll send IEx commands to the running server via tmux.
⏺ Bash(tmux send-keys -t petal_pro 'admin = PetalPro.Accounts.get_user_by_email("admin@example.com"); [%{description: "Salary", amount: "5200.00", type: "income", date: ~D[2026-05-01], user_id: admin.id}, %{description: "Rent", amount: "1800.00", type: "expense", date: ~D[2026-05-01], user_id: admin.id}, %{description: "Groceries", amount: "142.50", type: "expense", date: ~D[2026-05-02], user_id: admin.id}, %{description: "Freelance project", amount: "3500.00", type: "income", date: ~D[2026-05-02], user_id: admin.id}] |> Enum.each(&PetalPro.Finance.create_transaction/1)' Enter)
⎿ :ok
⏺ 4 transactions created. Reload http://localhost:4000/app/transactions.
```
Reload the page:

Transactions table — green income badges, red expense badges, colour-coded amounts
The new transaction form is equally clean:

New transaction modal — type dropdown, date defaults to today
For new users with no transactions yet:

Empty state — sidebar, menu, and branding already in place
If you want to see the full sign-up flow, sign out and register as a new user — click **"Continue with passwordless"**, enter any email, and check the terminal (or tmux session) for a 6-digit PIN. In development, emails aren't sent — the PIN is printed to the console. After PIN entry you'll see the onboarding screen, which collects the user's name before redirecting to the app.
## User activity logging
Petal Pro puts logging front and centre — it ends up in the admin panel where you can watch it live.
```
Add activity logging to the transactions feature — log when a transaction is
created. Include the description, amount, and type as metadata.
```
```
⏺ I'll add "transactions.create" to the allowed log actions, then call it after
a successful create.
⏺ Edit(lib/petal_pro/logs/log.ex)
⎿ Added "transactions.create" to @action_options
⏺ Edit(lib/petal_pro_web/live/transaction_live/form_component.ex)
⎿ Added after {:ok, transaction}:
PetalPro.Logs.log("transactions.create", %{
user: socket.assigns.current_user,
metadata: %{
transaction_id: transaction.id,
description: transaction.description,
amount: transaction.amount,
type: transaction.type
}
})
```
Create a transaction, then go to Admin → Logs. You'll see the entry. Turn on "Live logs" and open two windows side by side — one on logs, one on transactions. Add a transaction and watch the log entry appear in real time. That's Phoenix PubSub; nothing to configure.

Admin logs — real-time, filterable, with full metadata
## Monthly summary email
Let's build a monthly email showing users their income, expenses, and net for the month.
```
Build a monthly summary email for Financy. It should show total income, total
expenses, and net for the current month. Schedule it to run on the 1st of each
month at 9am via Oban. Also add it to the Dev email template previewer so I can
see it while building.
```
```
⏺ I'll build this in four parts: email function, template, preview, and Oban worker.
⏺ Edit(lib/petal_pro/notifications/email.ex)
⎿ Added monthly_summary/2 — builds a Swoosh email struct with a summary map
⏺ Write(lib/petal_pro_web/templates/email/monthly_summary.html.heex)
⎿ Template using EmailComponents.gray_box for the income/expenses/net summary
and EmailComponents.button_centered to link to /app/transactions
⏺ Edit(lib/petal_pro_web/controllers/email_testing_controller.ex)
⎿ Added "monthly_summary" to @email_templates and generate_email/2 clause
with sample data
⏺ Write(lib/petal_pro/workers/monthly_summary_worker.ex)
⎿ Oban worker — fetches this month's transactions per user, calculates totals,
sends email to users who have activity
⏺ Edit(lib/petal_pro/finance.ex)
⎿ Added monthly_summary_for/1 — returns %{total_income, total_expenses, net, month, url}
⏺ Edit(config/config.exs)
⎿ Added {"0 9 1 * *", PetalPro.Workers.MonthlySummaryWorker} to Oban crontab
⏺ Preview it at Admin → Dev → Email templates → monthly_summary.
Test manually: Oban.insert(PetalPro.Workers.MonthlySummaryWorker.new(%{}))
Check the captured email at http://localhost:4000/dev/mailbox.
```
How email works in Petal Pro: `email.ex` creates `%Swoosh.Email{}` structs (the view), `user_mailer.ex` delivers them (the controller). The dev mailbox at `/dev/mailbox` captures everything locally so nothing actually sends in development.
## In-app notifications
The notification bell in the sidebar is already wired up. Let's make it light up when a user's spending exceeds their income.
```
Add an in-app notification that fires when expenses exceed income for the month.
Wire it into the monthly summary worker so it runs at the same time. Users should
be able to turn it off from their notification preferences.
```
```
⏺ I'll register the notification type and trigger it from the worker.
⏺ Edit(lib/petal_pro/notifications/preferences.ex)
⎿ Added spending_exceeds_income: %{
channels: [:in_app, :email],
allow_disable_in_app: true,
allow_disable_email: true
}
⏺ Edit(lib/petal_pro/workers/monthly_summary_worker.ex)
⎿ Added Notifications.notify/3 call when Decimal.compare(summary.net, 0) == :lt:
PetalPro.Notifications.notify(
%{type: :spending_exceeds_income, user_id: user.id,
title: "Spending alert",
body: "Your expenses exceeded your income this month."},
user,
email_fun: nil
)
```
The bell badges in real time via PubSub when a notification arrives. Users can toggle it off from Settings → Notification preferences — that option is there automatically because we set `allow_disable_in_app: true`.
## Writing tests
```
The generated test files need updating — we changed the schema to require user_id.
Fix them. Also add a test for monthly_summary_for/1.
```
```
⏺ Bash(mix test test/petal_pro/finance_test.exs)
⎿ 3 failures — user_id not set in fixtures
⏺ Edit(test/petal_pro/finance_test.exs)
⎿ Added user setup in describe block
Updated all fixtures to include user_id
Added test: create_transaction with negative amount returns error changeset
Added test: monthly_summary_for sums income and expenses correctly
⏺ Bash(mix test test/petal_pro/finance_test.exs)
⎿ .....
5 tests, 0 failures
```
For a production app, you'd also want LiveView tests for the transaction pages — the generator creates a skeleton at `test/petal_pro_web/live/transaction_live_test.exs`. Ask Claude to fill those out too.
## Using Claude Code effectively
A few things that make working with Petal Pro and Claude Code smoother:
**Be specific about what you want.** "Add logging" works, but "log when a transaction is created — include the description, amount, and type as metadata, use dotted naming like `transactions.create`" gets it right first time.
**Ask for explanations after.** Claude will often make the right call without explaining why. If you want to understand a decision, just ask — "why did you use `Ecto.assoc` instead of a `where` clause?"
**Use tmux and the browser for live inspection.** Claude can send IEx expressions to the running server via tmux (`tmux send-keys -t petal_pro '...' Enter`) and read the output. It can also use the Claude browser extension to navigate the app, fill forms, and screenshot results directly — useful for testing UI flows without manual clicking.
**The CLAUDE.md files carry the context.** Don't explain Petal Pro conventions to Claude — they're already documented in the CLAUDE.md files across the project. Just describe your feature. If Claude gets something wrong, it's usually worth checking whether the relevant convention is in the docs and asking Claude to re-read it.
**Slash commands are your shortcuts.** Run `/find-skills` in Claude Code to see 20+ available skills covering common customizations — adding fields to users, generating admin pages, setting up webhooks, and more.
## Deployment with Fly.io
To deploy, follow the [Deploy to Fly.io guide](/petal-pro/guides/deploy-to-fly.io).
### Before you go live
Things v4 handles for you that you should switch on:
- **Error monitoring** — Set `SENTRY_DSN` on your Fly app. Auto-activates when present, nothing else to configure.
- **Rate limiting** — Auth endpoints are already rate-limited with Hammer. No configuration needed.
- **GDPR** — Users can export and delete their data from Settings → Your data. Soft-deletion and anonymization run automatically via Oban.
- **Email** — Set `MAIL_PROVIDER=resend` or configure SES credentials.
- **Billing** — If you're adding payments, see the [Stripe billing docs](/petal-pro/fundamentals/stripe-billing).
## From nothing to production
That's the full loop — branding, feature, logging, email, notifications, tests, deploy.
The thing worth reflecting on: most of the boilerplate was written by Claude while you described what you wanted. The CLAUDE.md files meant you never had to explain how Petal Pro works — Claude already knew. `use PetalPro.Schema`, `Ecto.assoc` for user-scoped queries, the notification system's `notify/3` API, the dotted log action naming convention — all of that context was already there.
The more you work in this project, the more that pattern pays off.
Things worth exploring next:
- **[Stripe billing](/petal-pro/fundamentals/stripe-billing)** — Add subscription plans or one-time purchases
- **[Organizations](/petal-pro/fundamentals/organizations-and-multitenancy)** — Scope transactions under orgs for shared household or business budgets
- **[Image uploads](/petal-pro/fundamentals/image-uploads)** — Attach receipts to transactions
- **[Generators](/petal-pro/fundamentals/generators)** — Use `petal.gen.live` to scaffold more features
If you have any feedback, head [here](https://petal.build/feedback) to get in touch.
---
# Deploy to Fly.io
# Source: https://petal.build/petal-pro/guides/deploy-to-fly.io
# Deploy to Fly.io
Ship your Petal Pro app to production on Fly.io.
## Before you start
There are a few things to think about before deploying — see [Deployment fundamentals](/petal-pro/fundamentals/deployment) for the full checklist.
If you're using Claude Code, the easiest path is to ask:
```
help me deploy this app to Fly.io
```
Claude knows the patterns from the CLAUDE.md files and will walk you through the steps below. The rest of this guide is the same flow, in case you want to do it yourself.
## Dev scripts
Petal Pro ships with a few convenience scripts in `/scripts/`:
```shell
sh scripts/server.sh # Start Phoenix + IEx in a tmux session named petal_pro
sh scripts/reset_db.sh # Drop, create, migrate, and seed the database
sh scripts/kill_db.sh # Kill stuck Postgres connections (handy when migrations hang)
```
## Launch on Fly.io
Fly.io is the cheapest, easiest place to host a Phoenix app. Petal Pro is configured to deploy there with minimal fuss.
Install the [Fly.io CLI](https://fly.io/docs/getting-started/installing-flyctl), then [sign in](https://fly.io/docs/getting-started/log-in-to-fly/).
Create a new app with:
```shell
fly launch
```
ℹ️ Information: Fly will prompt you with default settings and ask if you want to tweak them. Hit Y — it opens a browser configuration UI.
In the configuration UI:
- Give your app a name (you can't change it later).
- Under Database, choose **Fly Postgres**.
- Pick the cheapest server size to start.
- Leave Redis as **none**.
- Hit Confirm.
## The Dockerfile
Petal Pro v4 ships a production-ready `Dockerfile` in the repo root — you don't need to generate one with `mix phx.gen.release --docker`. It's a multi-stage build:
- **Builder stage**: Elixir 1.17.2 / OTP 27 on Debian Bookworm. Compiles deps, runs the npm asset pipeline, produces an Elixir release.
- **Runner stage**: Lean Debian Bookworm slim with only the compiled release — no Elixir or build tools in the production image.
`fly launch` detects the existing Dockerfile and uses it. If it tries to generate a new one, skip that step — the included Dockerfile already has `nodejs` and `npm` for the asset pipeline.
## Environment variables
`fly launch` automatically creates these secrets:
- `SECRET_KEY_BASE`
- `DATABASE_URL`
The generated `fly.toml` also sets:
- `PHX_HOST`
- `PORT`
### Required
```shell
# Email — required for auth flows (confirmation, password reset, etc.)
fly secrets set RESEND_API_KEY="re_..."
```
### Optional
```shell
# Sentry error tracking — auto-activates when set, no code changes needed
fly secrets set SENTRY_DSN="https://..."
# Admin MCP API bearer token (for AI tool access)
fly secrets set MCP_ADMIN_TOKEN="your_secure_token"
# Slack notifications (new signups, billing events, daily digest)
fly secrets set SLACK_OAUTH_TOKEN="xoxb-..."
fly secrets set SLACK_CHANNEL_ID="C0123456789"
```
## Auto-deploy on push to main
Petal Pro v4 includes a GitHub Actions workflow at `.github/workflows/fly.yml` that auto-deploys on push to `main`:
```yaml
name: Deploy to Fly.io
on:
push:
branches:
- main
concurrency:
group: fly-deploy
cancel-in-progress: false
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
```
To enable it:
1. `fly tokens create deploy -x 999999h` to generate a long-lived token
2. In your GitHub repo: **Settings → Secrets and variables → Actions**
3. Add a secret called `FLY_API_TOKEN` with the token value
`cancel-in-progress: false` queues deploys instead of cancelling them — so you don't end up half-deployed if you push twice in a row.
## Manual deploy
You can also deploy at any time with:
```shell
fly deploy
```
ℹ️ Information: If deployment fails, you may need to add a payment method — Fly only allows 1 machine per app on the free tier.
After deploying, run `fly open` to view it in the browser. You're live.
---
# Adding a Subscription
# Source: https://petal.build/petal-pro/guides/adding-a-subscription
# Adding a subscription
How to start charging customers in your Petal Pro app.
## Before you start
This guide picks up from where the [main tutorial](/petal-pro/guides/creating-a-web-app-from-start-to-finish) leaves off — you should have a Petal Pro app running locally. If you don't, follow that guide first to get a fresh app set up. Or just unzip Petal Pro into a fresh folder, run `mix setup`, start the server, and you're ready.
You'll also need:
- A [Stripe](https://stripe.com/) account
- The [Stripe CLI](https://stripe.com/docs/stripe-cli#install) installed locally
- An account on [Fly.io](https://fly.io/) if you want to deploy
ℹ️ Information: Stripe now offers Sandboxes as an alternative to test mode. We recommend using a Sandbox — it's an isolated environment so you can experiment without polluting your real account data.
## Configure Stripe
Make sure Stripe is in test mode (or use a Sandbox), then sign in via the CLI:
```shell
stripe login
```
### Create your products
Petal Pro comes pre-configured for three plans: **Essential**, **Business**, and **Enterprise** with monthly and yearly prices. You'll create those products in Stripe and grab the IDs.
Create the Essential product:
```shell
stripe products create --name="Essential"
```
Copy the `id` from the response — it looks like `prod_xxxxx`.
Create the monthly price:
```shell
stripe prices create \
--unit-amount=1900 \
--currency=usd \
-d "recurring[interval]"=month \
--product="prod_xxxxx"
```
ℹ️ Information:--unit-amount is in cents, not dollars.
Now the yearly price:
```shell
stripe prices create \
--unit-amount=19900 \
--currency=usd \
-d "recurring[interval]"=year \
--product="prod_xxxxx"
```
Repeat the whole process for **Business** and **Enterprise**. Suggested pricing:
| Product | Essential | Business | Enterprise |
| --- | --- | --- | --- |
| Monthly | $19 | $49 | $99 |
| Yearly | $199 | $499 | $999 |
You'll end up with six prices in total. Keep them somewhere — you'll paste them into your Petal Pro config shortly.
### Allow plan switching in the Customer Portal
When users want to change plans, Stripe handles it through the Customer Portal. You need to enable plan switching:
1. In Stripe (test mode), click the cog (top right) → **Settings**
2. In **Billing**, click **Customer Portal**
3. Expand **Subscriptions**
4. Enable **Customers can switch plans**
5. Add prices from Essential, Business, and Enterprise via "Find a test product..."
You might also want to fill in **Business information** to customise the portal's branding.
## Configure Petal Pro
Three things to do locally:
1. Run the Stripe webhook listener
2. Add Stripe secrets to your environment
3. Update the Petal Pro config with your product/price IDs
### Webhook listener
```shell
stripe listen --forward-to localhost:4000/webhooks/stripe
```
Output will include a webhook signing secret like `whsec_xxxxxxxxxxxxxxx`. Save it — that's your `STRIPE_WEBHOOK_SECRET`.
Keep this command running in a terminal (or in tmux). If you're using Claude Code, ask:
```
run the stripe webhook listener in tmux so it keeps going in the background
```
### Environment variables
You need:
- `STRIPE_SECRET` — get it from Stripe → **Developers** → **API keys** → **Reveal test key**
- `STRIPE_WEBHOOK_SECRET` — from the `stripe listen` output above
Petal Pro uses [direnv](https://direnv.net/) for local environment variables. Install it, then:
```shell
cp .envrc.example .envrc
```
Uncomment and fill in the Stripe variables at the bottom of `.envrc`. The `.envrc` file is gitignored — your secrets stay local.
Activate them:
```shell
direnv allow
```
### Update the product config
Open `config/config.exs` and find `:billing_products`. You'll see placeholder Stripe price IDs like `price_1NLhPDIWVkWpNCp7trePDpmi`. Replace each one with the real IDs from your Stripe account.
To list the prices for a product:
```shell
stripe prices list --product=prod_xxxxx
```
If you'd rather not chase down IDs manually, ask Claude:
```
I've created Essential, Business, and Enterprise products in Stripe. Update
config.exs with the real price IDs by listing prices via the Stripe CLI.
```
## Test it
Sign in to your app as the admin user (`admin@example.com` / `password`).
Navigate to **Organizations** in the sidebar. Click your org, then **Subscribe**. You'll see your three plans with pricing.
Click **Subscribe** on a plan. You'll be redirected to Stripe's checkout. Use the test card `4242 4242 4242 4242` with any expiry and CVC. Submit.
You'll be returned to the billing success page with your subscription active.
ℹ️ Information: If you see a spinner that never resolves, your Stripe webhook listener isn't running. Restart it with stripe listen --forward-to localhost:4000/webhooks/stripe.
To verify subscription-protected routes work, navigate to `/app/org/:org_slug/subscribed_live` — you should see a page that was previously blocked.
To add your own subscriber-only routes, edit `lib/petal_pro_web/routes/subscription_routes.ex`.
## Org vs user subscriptions
Subscriptions can belong to organizations or individual users. The default is `:org`:
config/config.exs
```elixir
# :org is the default. Set to :user to switch.
config :petal_pro, :billing_entity, :org
```
The relevant routes for org-based subscriptions:
```
/app/org/:org_slug/subscribe # Pick a plan
/app/org/:org_slug/subscribe/success # After purchase
/app/org/:org_slug/billing # Manage subscription
/app/org/:org_slug/subscribed_live # Subscriber-only example route
```
For user-based subscriptions:
```
/app/subscribe
/app/subscribe/success
/app/billing
/app/subscribed_live
```
In `subscription_routes.ex` there are two scope blocks — one for `:user` mode, one for `:org` mode. Add new subscriber-only routes inside the appropriate block.
## Deploy to production
Three steps:
1. Push the app to Fly.io
2. Set up a webhook endpoint on Stripe pointing at your Fly app
3. Set Stripe secrets on Fly.io
### Push to Fly.io
Follow the [Deploy to Fly.io guide](/petal-pro/guides/deploy-to-fly.io).
### Stripe webhook endpoint
In Stripe (test mode):
1. **Developers** → **Webhooks** → **Add endpoint**
2. **Endpoint URL**: `https://your-app-name.fly.dev/webhooks/stripe`
3. Under **Select events to listen to**, add:
- `customer.subscription.created`
- `customer.subscription.updated`
4. Click **Add endpoint**
### Fly.io secrets
You'll need:
- `STRIPE_SECRET` — from **Developers** → **API keys** → **Reveal test key**
- `STRIPE_WEBHOOK_SECRET` — from the Stripe webhook endpoint you just created (click **Reveal** under "Signing secret")
```shell
fly secrets set \
STRIPE_SECRET="sk_test_xxx" \
STRIPE_WEBHOOK_SECRET="whsec_xxx" \
STRIPE_PRODUCTION_MODE="false"
```
After Fly redeploys, you should be able to subscribe through your live app using test cards.
## Going live
Everything so far has been in test mode. To switch to production:
### CLI commands in live mode
The Stripe CLI defaults to test mode. To work with live data, append `--live`:
```shell
stripe products list --live
```
Not every command supports `--live`, but the ones in this guide do.
### Recreate products in live mode
Repeat the [Configure Stripe](#configure-stripe) section in live mode. You'll get new product and price IDs.
Before you update production `config.exs`, **copy the existing test-mode billing config to `config/dev.exs`** so your dev environment keeps working with test prices:
```elixir
config :petal_pro, :billing_products, [
%{
id: "essential",
name: "Essential",
# ... your test-mode config
}
]
```
Then update `config.exs` with the live-mode product/price IDs. Your dev machine uses test mode, your production server uses live mode.
Set `STRIPE_PRODUCTION_MODE=true` on Fly.io once you're ready to take real payments.
---
# Metered Usage
# Source: https://petal.build/petal-pro/guides/metered-usage
# Metered usage
How to charge customers based on how much they actually use — instead of fixed monthly tiers.
## Before you start
Metered usage is a step beyond a regular subscription. You should have read [Adding a subscription](/petal-pro/guides/adding-a-subscription) first, and have a Petal Pro app running locally.
The differences with metered billing:
- In **Stripe** you create a **Meter** plus prices configured for usage rather than a fixed amount
- In **Petal Pro** you call `Meters.record_event/4` whenever a billable action happens, and a background worker syncs those events to Stripe
This guide walks through wiring metered usage into the built-in AI chat feature — billing customers per AI token consumed.
You'll need:
- A working Petal Pro app
- A [Stripe](https://stripe.com/) account with the [CLI](https://stripe.com/docs/stripe-cli#install) installed
- The Stripe CLI logged in (`stripe login`)
ℹ️ Information: Stripe now offers Sandboxes as an alternative to test mode. We recommend using a Sandbox so you can experiment cleanly without affecting your real account.
## Configure Stripe
### Create a metered product
```shell
stripe products create --name="Essential"
```
Save the returned `id` (looks like `prod_xxxxx`).
Now create a metered price. The key is `usage_type=metered`:
```shell
stripe prices create \
--unit-amount=5 \
--currency=usd \
-d "transform_quantity[divide_by]"=1000 \
-d "transform_quantity[round]"=up \
-d "recurring[interval]"=month \
-d "recurring[usage_type]"=metered \
--product="prod_xxxxx"
```
Translation: 5 cents per 1,000 units, rounded up. So a single use costs at least $0.05.
ℹ️ Information:--unit-amount is in cents.
Repeat the process for **Business** and **Enterprise** at different rates:
| Plan | Rate |
| --- | --- |
| Essential | $0.05 per 1,000 units |
| Business | $0.50 per 15,000 units |
| Enterprise | $5 per 200,000 units |
### Allow plan switching
Same as a regular subscription:
1. Stripe → cog → **Settings** → **Billing** → **Customer Portal**
2. Expand **Subscriptions**
3. Enable **Customers can switch plans**
4. Add the metered prices from each product
ℹ️ Information: If you can't add metered prices, remove all existing prices first then re-add.
### Create a Meter
A Meter groups usage events under a feature. We're going to track AI tokens:
```shell
stripe billing meters create \
--display-name="AI tokens" \
--event-name=ai_tokens \
-d "default_aggregation[formula]"=sum
```
ℹ️ Information: The billing command is a recent addition to the Stripe CLI. Make sure you're on the latest version.
## Configure Petal Pro
Same three steps as a regular subscription:
1. Run the webhook listener (`stripe listen --forward-to localhost:4000/webhooks/stripe`) — keep it running in tmux
2. Set `STRIPE_SECRET` and `STRIPE_WEBHOOK_SECRET` in `.envrc`
3. Update product/price IDs in `config.exs`
If you've already done the [subscription guide](/petal-pro/guides/adding-a-subscription), you've already got steps 1 and 2. For step 3, your config will look slightly different — metered prices use `unit_amount` and `per_units` instead of a flat `amount`, and you can drop `trial_days`.
In this guide we're using monthly pricing only, so you can also remove `essential-yearly` from the config.
When you call Stripe to subscribe a user, you must omit `quantity` for metered prices — passing it returns a Stripe error.
To grab your meter and price IDs:
```shell
stripe billing meters list
stripe prices list --product=prod_xxxxx
```
Or just ask Claude:
```
I've created Essential, Business, Enterprise products in Stripe with metered
prices, plus an "ai_tokens" meter. Update config.exs with the real IDs.
```
### Enable the CollectMeterEvents GenServer
Recording events shouldn't block the calling process. Petal Pro queues events through `CollectMeterEvents` — uncomment it in `lib/petal_pro/application.ex`:
application.ex
```diff
defmodule PetalPro.Application do
def start(_type, _args) do
children = [
...
- # PetalPro.Billing.Meters.CollectMeterEvents,
+ PetalPro.Billing.Meters.CollectMeterEvents,
...
]
...
end
end
```
### Enable the sync worker
`MeterSyncWorker` is the Oban job that uploads queued events to Stripe. Uncomment it in `config.exs`:
ℹ️ Information: Notice the billing queue alongside default. Stripe-related jobs run on the billing queue so they can be monitored separately in Oban Web.
## How metered usage works
Two halves: recording events and syncing them to Stripe.
### Recording usage
```elixir
alias PetalPro.Billing.Meters
Meters.record_event(meter_id, customer_id, subscription_id, quantity)
```
Non-blocking — it queues the event through `CollectMeterEvents`, which writes it to the `billing_meter_events` table in the background.
### Syncing to Stripe
Petal Pro is the source of truth for metered events. The Stripe API doesn't let you filter usage by `subscription_id`, but the Petal Pro database does — which is what makes the billing page able to show upcoming charges per subscription.
`MeterSyncWorker` runs daily by default and uploads unprocessed events to Stripe. Events are marked processed when uploaded successfully or when an error occurs.
🚨 Heads up: Events with errors won't sync to Stripe. Check the billing_meter_events table for error messages. Clear an event's error_message to make it eligible for re-processing.
For testing in dev, you might want to run the worker more often. Switch the cron schedule:
config/config.exs
```diff
- {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}
+ {"* * * * *", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}
```
Or trigger one manually in IEx:
```elixir
PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker.new(%{}) |> Oban.insert()
```
## Wiring it into AI chat
Now to make AI chat actually charge per token. The plan:
1. Remove the month/year toggle on the subscribe page (we're metered now)
2. Move AI chat behind the subscription routes (we need access to the customer + subscription)
3. Update the menu
4. Capture token usage from the AI calls
5. Call `Meters.record_event/4` from the LiveView
If you'd rather skip the manual steps, ask Claude:
```
Wire AI chat into metered billing: remove the interval selector from the subscribe
page, move the AI chat route into SubscriptionRoutes, update menus.ex, and add
token tracking via Meters.record_event/4 in user_ai_chat_live.
```
The rest of this section walks through it manually.
### Remove the month/year toggle
In `subscribe_live.ex`, drop the `interval_selector` attribute:
subscribe_live.ex
```diff
-
+
```
### Move AI chat behind subscription
Recording usage needs `customer_id` and `subscription_id`. The simplest way to have those in the LiveView's assigns is to mount under `SubscriptionRoutes`.
Remove the existing AI chat route from `router.ex`:
router.ex
```diff
live "/users/two-factor-authentication", EditTotpLive
- live "/ai-chat", UserAiChatLive
live "/orgs", OrgsLive, :index
```
Add it inside `SubscriptionRoutes` instead — both the `:user` and `:org` blocks:
subscription_routes.ex
```diff
live_session :subscription_authenticated_user, ... do
live "/subscribed_live", SubscribedLive
+ live "/ai-chat", UserAiChatLive
end
live_session :subscription_authenticated_org, ... do
live "/subscribed_live", SubscribedLive
+ live "/ai-chat", UserAiChatLive
end
```
For `:user` subscriptions the URL is `http://localhost:4000/app/ai-chat`. For `:org` subscriptions it becomes `http://localhost:4000/app/org/your-org-slug/ai-chat`. Either way the LiveView mounts with `customer` and `subscription` already in assigns.
### Update the menu
Remove the unrestricted AI chat link from `menus.ex` and add it to the org menu (assuming you're using `:org` subscriptions). Then add a `get_link/2` clause for `:org_chat_ai` pointing to the new path.
### Capture token usage
Petal Pro v4 uses [Jido AI](https://hex.pm/packages/jido_ai) (replacing LangChain). Cost tracking happens automatically — every API call is logged to the `ai_call_logs` table with token counts via a Req plugin.
For metered billing, you also need that data inside the LiveView so you can call `Meters.record_event/4`. Configure the AI client's token usage callback to send a message back to `self()`.
### Record the event
Add a `handle_info` callback in `user_ai_chat_live`:
```elixir
@impl true
def handle_info({:token_usage, %{input: input, output: output}}, socket) do
meter = PetalPro.Billing.Meters.get_meter_by_event_name("ai_tokens")
total_tokens = input + output
PetalPro.Billing.Meters.record_event(
meter.id,
socket.assigns.customer.id,
socket.assigns.subscription.id,
total_tokens
)
{:noreply, socket}
end
```
Because the LiveView is mounted under the subscription routes, `customer` and `subscription` are already in assigns. We just look up the meter and record the event.
## Testing it
Sign in as admin (`admin@example.com` / `password`).
1. Go to **Organizations** → click your org → **Subscribe**
2. Pick a plan, complete checkout with `4242 4242 4242 4242`
3. Once subscribed, click **AI Chat** in the org menu
4. Ask the AI a question
Verify the event was recorded by querying `billing_meter_events`. Or ask Claude:
```
Use IEx to show me the latest billing_meter_events
```
To see the upcoming charges in Stripe, run `MeterSyncWorker` (or wait for the cron), then go to your org → **Org Settings** → **Billing**. You should see the upcoming amount and your AI Tokens usage.
ℹ️ Information: Stripe takes a moment to update upcoming invoices — give it 30 seconds.
## Deploy
Same as a regular subscription — see the [subscription deployment section](/petal-pro/guides/adding-a-subscription#deploy-to-production). The Stripe webhook events you need are the same:
- `customer.subscription.created`
- `customer.subscription.updated`
The metered billing happens server-side via the `MeterSyncWorker` cron — make sure your Fly.io app is keeping its workers alive.
## Going live
Same flow as the [subscription guide's "Going live"](/petal-pro/guides/adding-a-subscription#going-live) — recreate your products and the meter in Stripe live mode, copy your test-mode billing config to `config/dev.exs` so dev keeps working with test prices, then update `config.exs` with the live IDs and set `STRIPE_PRODUCTION_MODE=true` on Fly.
---
# Creating Your Own API
# Source: https://petal.build/petal-pro/guides/creating-your-own-api
# Creating your own API
Petal Pro ships with a JSON REST API for user management — register, sign in, and update profile. It's documented with OpenAPI/Swagger out of the box. This guide shows how to extend it with your own endpoints.
For background on the included API, see [REST API fundamentals](/petal-pro/fundamentals/rest-api).
## Using the Swagger UI
Petal Pro generates an interactive Swagger UI for the API at:
```
http://localhost:4000/dev/swaggerui
```
To call an endpoint:
1. Expand a route (e.g. `/api/register`)
2. Click **Try it out**
3. Fill in parameters and request body
4. Click **Execute**
Some routes are protected — calling them without authentication returns `401 Unauthorized`.
To authenticate:
1. Run `/api/sign-in` with an email and password
2. Copy the `token` from the response
3. Click **Authorize** at the top of the page
4. Paste the bearer token and click **Authorize** again
The token is valid for 30 days by default.
## Generating the OpenAPI spec
To export the spec as JSON:
```shell
mix openapi.spec.json --spec PetalProApi.ApiSpec
```
You can also save it from the Swagger UI — click the link under the "Petal Pro API" heading and use your browser's "Save As".
## Extending the API
Let's add an endpoint that returns the list of organizations a user belongs to. The path forward:
1. Build the controller
2. Add the route
3. Write a test
4. Add authentication checks
5. Add OpenAPI documentation
If you'd rather just describe what you want and let Claude do the typing, drop this into Claude Code:
```
Add an API endpoint at GET /api/user/:id/orgs that returns a list of organization
names for the user. Protect it so users can only fetch their own orgs (admins
can fetch any user's orgs). Add tests and OpenAPI docs.
```
Claude knows the conventions from the CLAUDE.md files. The rest of this guide walks through what gets built.
### The controller
Create `lib/petal_pro_api/controllers/membership_controller.ex`:
membership_controller.ex
```elixir
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
alias PetalPro.Accounts
alias PetalPro.Orgs
action_fallback PetalProWeb.FallbackController
def list(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
org_names = user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
```
### The route
Add to `lib/petal_pro_api/routes.ex` inside the existing scope:
routes.ex
```elixir
scope "/api", PetalProApi do
pipe_through [:api, :api_authenticated]
get "/user/:id", ProfileController, :show
patch "/user/:id/update", ProfileController, :update_profile
post "/user/:id/request-new-email", ProfileController, :request_new_email
post "/user/:id/change-password", ProfileController, :change_password
get "/user/:id/orgs", MembershipController, :list # add this
end
```
### A test
Create `test/petal_pro_api/membership_controller_test.exs`:
membership_controller_test.exs
```elixir
defmodule PetalProApi.MembershipControllerTest do
use PetalProWeb.ConnCase
setup %{conn: conn} do
user = PetalPro.AccountsFixtures.confirmed_user_fixture()
org = PetalPro.OrgsFixtures.org_fixture(user)
membership = PetalPro.Orgs.get_membership!(user, org.slug)
{:ok,
conn: put_req_header(conn, "accept", "application/json"),
user: user,
org: org,
membership: membership}
end
describe "list" do
test "returns the user's organizations", %{conn: conn, user: user} do
conn = get(conn, ~p"/api/user/#{user.id}/orgs")
assert orgs = json_response(conn, 200)
assert Enum.count(orgs) > 0
end
end
end
```
If you run it now:
```shell
mix test test/petal_pro_api/membership_controller_test.exs
```
It fails:
```
1) test list returns the user's organizations
** (RuntimeError) expected response with status 200, got: 401
```
That's because the route is in the `:api_authenticated` pipeline and our test hasn't set up a bearer token.
### Authenticating in tests
`put_bearer_token/2` is a helper in `/test/support/conn_case.ex`. It generates a bearer token for a user and adds it to the request as an `Authorization` header.
Update the test:
```elixir
test "returns the user's organizations", %{conn: conn, user: user} do
conn =
conn
|> put_bearer_token(user)
|> get(~p"/api/user/#{user.id}/orgs")
assert orgs = json_response(conn, 200)
assert Enum.count(orgs) > 0
end
```
Now `mix test` passes.
### Authorization (not just authentication)
A bearer token proves *who* the user is. But you usually also want to check *what they're allowed to do*. In our case, users should only be able to fetch their own orgs — unless they're admins, in which case they can fetch anyone's.
Petal Pro has a `match_current_user` plug that does exactly this. It compares the URL's `:id` with the authenticated user's ID. If they match (or the authenticated user is an admin), the plug puts the user into `conn.assigns.user`. Otherwise it returns `403 Forbidden`.
Wire it into the controller:
membership_controller.ex
```elixir
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
alias PetalPro.Orgs
alias PetalProApi.Plugs.MatchCurrentUser
action_fallback PetalProWeb.FallbackController
plug MatchCurrentUser
def list(conn, _params) do
org_names = conn.assigns.user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
```
The `list` function now reads the user from `conn.assigns.user` instead of looking it up from the URL — by the time the action runs, the plug has already validated and set it.
Add tests for the authorization logic:
```elixir
setup %{conn: conn} do
user = PetalPro.AccountsFixtures.confirmed_user_fixture()
other_user = PetalPro.AccountsFixtures.confirmed_user_fixture()
admin_user = PetalPro.AccountsFixtures.admin_fixture()
org = PetalPro.OrgsFixtures.org_fixture(user)
{:ok,
conn: put_req_header(conn, "accept", "application/json"),
user: user, other_user: other_user, admin_user: admin_user, org: org}
end
describe "list" do
test "user can fetch their own orgs", %{conn: conn, user: user} do
conn = conn |> put_bearer_token(user) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 200)
end
test "user cannot fetch another user's orgs", %{conn: conn, other_user: other, user: user} do
conn = conn |> put_bearer_token(other) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 403)
end
test "admin can fetch any user's orgs", %{conn: conn, admin_user: admin, user: user} do
conn = conn |> put_bearer_token(admin) |> get(~p"/api/user/#{user.id}/orgs")
assert json_response(conn, 200)
end
end
```
All three pass. The endpoint is now correctly authenticated *and* authorized.
## Adding it to the OpenAPI spec
Your endpoint works, but it doesn't show up in the Swagger UI yet. Petal Pro uses [OpenApiSpex](https://github.com/open-api-spex/open_api_spex) — you describe each endpoint with metadata in the controller.
In `membership_controller.ex` add:
```elixir
defmodule PetalProApi.MembershipController do
use PetalProWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias PetalPro.Orgs
alias PetalProApi.Plugs.MatchCurrentUser
action_fallback PetalProWeb.FallbackController
plug MatchCurrentUser
tags(["Memberships"])
security([%{"authorization" => []}])
operation(:list,
summary: "List user's organizations",
description: "Returns the names of all organizations the user belongs to.",
parameters: [
id: [in: :path, type: :string, description: "User ID", required: true]
],
responses: [
ok: {"List of organization names", "application/json", OrganisationNames},
unauthorized: {"Unauthorized", "application/json", PetalProApi.Schemas.Error},
forbidden: {"Forbidden", "application/json", PetalProApi.Schemas.Error}
]
)
def list(conn, _params) do
org_names = conn.assigns.user |> Orgs.list_orgs() |> Enum.map(& &1.name)
json(conn, org_names)
end
end
```
A quick walk-through of what each piece does:
- `tags(["Memberships"])` groups this controller's endpoints under "Memberships" in the Swagger UI
- `security([%{"authorization" => []}])` tells OpenAPI that endpoints here use bearer auth (the `"authorization"` key references a `securityScheme` defined in `lib/petal_pro_api/api_spec.ex`)
- `operation(:list, ...)` describes the `list` action — its summary, parameters, and possible responses
Now define the response schema. Add to `lib/petal_pro_api/schemas.ex`:
schemas.ex
```elixir
defmodule OrganisationNames do
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "OrganisationNames",
description: "List of organisation names for a user",
type: :array,
items: %Schema{description: "name", type: :string},
example: ["Acme Inc", "Globex Corp"]
})
end
```
That's it. Restart the server, refresh `/dev/swaggerui`, and your endpoint shows up under "Memberships" with full documentation, the padlock indicating it requires auth, and an interactive request form.
---
# Content Editor Plugin
# Source: https://petal.build/petal-pro/guides/content-editor-adding-your-own-plug-in
# Content Editor — adding your own plug-in
Adding an Editor.js plug-in to Petal Pro and wiring up the renderer.
## Introduction
The [Content Editor component](https://petal.build/components/content-editor) wraps [Editor.js](https://editorjs.io/), which is plug-in based. Petal Pro ships with a [set of plug-ins](/petal-pro/fundamentals/blog-cms#pre-installed-editor.js-plug-ins) for the common stuff — text, lists, tables, images, etc.
When you want to add your own plug-in, there are two pieces:
1. The plug-in itself (a JavaScript class)
2. An update to the Petal Pro renderer so it knows how to display the data your plug-in produces
Both are easier than they look.
## What we'll build
A Markdown plug-in. Requirements:
- User pastes markdown into a textarea inside the editor
- The markdown is stored as-is in the Editor.js JSON
- When the post is rendered, the markdown is converted to HTML using [Earmark](https://hexdocs.pm/earmark)
If you'd rather skip the manual steps, drop this into Claude Code:
```
Add a custom Editor.js plug-in for Markdown. The plug-in should let users paste
markdown into a textarea, save it as-is in the editor JSON, and render it as
HTML using Earmark. Wire it into the Content Editor renderer.
```
The rest of this guide is the same flow, manually.
## Folder layout
The relevant files for Editor.js in Petal Pro:
```
├── assets
│ ├── css
│ │ └── editorjs.css
│ ├── js
│ │ ├── editorjs
│ │ │ └── petal-image.js # Built-in plug-in for File Browser
│ │ └── hooks
│ │ └── editorjs-hook.js # The Phoenix LiveView hook
│ └── package.json # Where you install Editor.js plug-ins
└── lib
└── petal_pro_web
└── components
└── pro_components
└── content_editor.ex # Component + renderer
```
Your new plug-in will sit alongside `petal-image.js`.
## Build the plug-in
Create `assets/js/editorjs/petal-markdown.js`:
petal-markdown.js
```javascript
export default class PetalMarkdown {
static get toolbox() {
return {
title: "Markdown",
icon: '',
};
}
constructor({ data, block }) {
this.data = data;
this.block = block;
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement("div");
const markdown_input = document.createElement("textarea");
markdown_input.placeholder = "Paste your markdown here...";
markdown_input.value = this.data.markdown || "";
markdown_input.classList.add("petal-markdown");
this.wrapper.append(markdown_input);
return this.wrapper;
}
save(blockContent) {
const markdown_input = blockContent.querySelector("textarea");
return {
markdown: markdown_input ? markdown_input.value : "",
};
}
}
```
Three methods worth understanding:
- **`toolbox()`** — Defines what shows up in the "+" block menu inside the editor. Title and icon.
- **`render()`** — Returns the DOM the user interacts with. Here, just a textarea.
- **`save()`** — Returns the data structure to persist into the editor's JSON output. We save the raw markdown.
## Style the textarea
Add to `assets/css/editorjs.css`:
editorjs.css
```css
.codex-editor textarea.petal-markdown {
@apply w-full rounded-md min-h-32 bg-gray-100 dark:bg-gray-950 dark:text-gray-400 border-gray-200 dark:border-gray-600 ring-0;
}
```
## Register it in the hook
In `assets/js/hooks/editorjs-hook.js`, import the plug-in and add it to the `tools` config:
editorjs-hook.js
```javascript
import EditorJS from "@editorjs/editorjs";
import PetalMarkdown from "../editorjs/petal-markdown"; // add this
const EditorJsHook = {
mounted() {
this.editor = new EditorJS({
tools: {
petalMarkdown: PetalMarkdown, // add this
// ... other tools
},
});
},
};
export default EditorJsHook;
```
Reload your editor, click the "+" button, pick **Markdown**, and a textarea appears. Type or paste markdown into it. The first two requirements are done — the plug-in works inside the editor and saves data correctly.
## Update the renderer
Now we need Petal Pro to actually render the markdown as HTML when displaying a post. Open `lib/petal_pro_web/components/pro_components/content_editor.ex`. You'll find a `content/1` function that decodes the JSON and dispatches each block to a `block/1` function:
```elixir
attr :json, :string, required: true
def content(assigns) do
json_object = decode_json(assigns.json)
blocks =
case json_object do
%{"blocks" => blocks} -> MapExt.atomize_keys(blocks)
_ -> []
end
assigns = assign(assigns, :blocks, blocks)
~H"""
<.block :for={block <- @blocks} block={block} />
"""
end
```
`block/1` uses pattern matching to render different block types. Add a clause for our plug-in *above* the catch-all:
content_editor.ex
```elixir
defp block(%{block: %{type: "petalMarkdown", data: %{markdown: markdown}}} = assigns) do
assigns = assign(assigns, :markdown, markdown)
~H"""
"""
end
# Catch-all (must come after the specific clauses)
defp block(assigns) do
~H""
end
```
`PetalProWeb.Markdown.pretty_markdown/1` uses Earmark under the hood. That's the third requirement done — markdown saved by your plug-in now renders as styled HTML on the public side.
## Installing third-party plug-ins
To use one of the [community plug-ins](https://github.com/editor-js/awesome-editorjs), install it via npm:
```shell
cd assets
npm add @editorjs/checklist
```
Then register it in `editorjs-hook.js` the same way as our custom plug-in:
```javascript
import Checklist from "@editorjs/checklist";
// In tools:
checklist: {
class: Checklist,
inlineToolbar: true,
}
```
And add a `block/1` clause in `content_editor.ex` to render whatever data structure the plug-in saves. Each plug-in's docs tell you the shape of its output JSON.