πŸš€Creating a web app from start to finish

Follow a step by step guide to creating a reminders web application using the Petal Pro boilerplate. We will cover everything from setup to deploying to production.

Note: This tutorial was written using Petal Pro v1.8.0

Getting up and running

What you need

For this tutorial you will need:

  • Access to Petal Pro (you can purchase it on petal.build)

  • Elixir & Erlang installed (you can follow our installation guide to get up and running)

  • PostgreSQL running: Postgres.app works well for Mac or we have a docker-compose.yaml file that will run Postgres for you

  • For deployment (optional) you will need:

Download

Head over to the projects page and create a new project, then download v1.7.0. You can unzip it in your terminal with the command unzip petal_pro_1.7.0.zip.

Renaming your project

Once it's unzipped we can go ahead and rename the folder to whatever we want to call it. In our case, we're going to call it "remindly".

Terminal
mv petal_pro_1.7.0 remindly
cd remindly

While this changes the folder name, we still need to change the name of all the files and module names (eg. petal_pro_web.ex should be remindly_web.ex, etc). We have created a library to do this called rename_project.

terminal
mix deps.get
mix rename PetalPro Remindly

Note: when you start working on your own project, if you plan to upgrade Petal to new versions as they are released we recommend not renaming your project so there are less conflicts.

Test ru

Let's get the server running so we can see Petal Pro in action. Simply run in your terminal:

mix setup
mix phx.server

And now navigate to http://localhost:4000 in your browser to see the dummy landing page:

Feel free to have a look around the website.

The mix setup command helpfully ran our seeds.ex file, which if you open you will see this code:

seeds.exs
# ...

if Mix.env() == :dev do
  Remindly.Repo.delete_all(Log)
  Remindly.Repo.delete_all(UserTOTP)
  Remindly.Repo.delete_all(Invitation)
  Remindly.Repo.delete_all(Membership)
  Remindly.Repo.delete_all(Org)
  Remindly.Repo.delete_all(UserToken)
  Remindly.Repo.delete_all(User)

  admin = UserSeeder.admin()

  normal_user =
    UserSeeder.normal_user(%{
      email: "user@example.com",
      name: "Sarah Cunningham",
      password: "password",
      confirmed_at: Timex.to_naive_datetime(DateTime.utc_now())
    })

  org = OrgSeeder.random_org(admin)
  Remindly.Orgs.create_invitation(org, %{email: normal_user.email})

  UserSeeder.random_users(20)
end

The UserSeeder.admin() function has created an admin for us. You can sign in as this admin with the details below:

Email: admin@example.com
Password: password

Adding your brand

Configuration

Obviously, the Petal branding will need to be replaced by your own brand. If you do a global search for "SETUP_TODO" you will find the code you need to modify.

The first mention you can ignore. The second match is in our config.exs file. Here we can update our web apps details:

config.exs
...

# SETUP_TODO - ensure these details are correct
# Option descriptions:
# app_name: This appears in your email layout and also your meta title tag
# support_email: In your transactional emails there is a "Contact us" email - this is what will appear there
# mailer_default_from_name: The "from" name for your transactional emails
# mailer_default_from_email: The "from" email for your transactional emails
# dark_logo_for_emails: Your logo must reference a URL for email client (not a relative path)
# light_logo_for_emails: Same as above
# seo_description: Will go in your meta description tag
# css_theme_default: Can be "light" or "dark" - if "dark" it will add the class "dark" to the <html> element by default
config :remindly,
  app_name: "Remindly",
  business_name: "Remindly Pty Ltd",
  support_email: "matt@petal.build",
  mailer_default_from_name: "Remindly",
  mailer_default_from_email: "matt@petal.build",
  logo_url_for_emails: "https://res.cloudinary.com/wickedsites/image/upload/v1646277219/petal_marketing/remindly_logo_tsvksl.png",
  seo_description: "Reminder app",
  twitter_url: "https://twitter.com/PetalFramework",
  github_url: "https://github.com/petalframework",
  discord_url: "https://discord.gg/exbwVbjAct"
...

The next SETUP_TODO is found in the file dev.ex, which is to regenerate and replace the provided secret_key_base with mix phx.gen.secret

The next SETUP_TODO is found in the file core_components.ex.

  # SETUP_TODO
  # This module relies on the following images. Replace these images with your 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

You don't need to change anything in this file, but rather it's asking you to update the logos with your own. For this tutorial, I went into Figma and just wrote "Remindly" in an interesting font. You can duplicate my Figma file and replace it with your logo if you like.

When you're ready to export you can highlight them all and click "Export":

Once you've exported them all you can drag them into your project:

Note that in case you missed it - since emails can be opened in all kinds of email clients, the logo must be hosted online somewhere.

I recommend uploading your logo in png format to some kind of image hosting you like (at least until you have a production server hosting the images for you). We personally like Cloudinary so let's upload our logo and then paste the URL into our config file. After you've done this, you can just paste the link in and your emails should reflect your new logo. We'll also discuss this later in the "Reminder emails" section of this tutorial.

Now restart your server, reload your browser and check out your new logo!

The other SETUP_TODO's are self-explanatory - they involve adding your license and privacy policy content as most SAAS applications will require them. You can add this content in pure markdown and we've provided some dummy content as an example to get you started. It's not really needed until you go live to production so you can put it off for now.

Tailwind primary and secondary colors

Petal Components relies heavily on mapping colors to different types, such as "primary", "secondary", "success", etc. This mapping is done in the tailwind.config.js file.

By default, primary is blue and secondary is pink:

tailwind.config.js
const colors = require("tailwindcss/colors");
const plugin = require("tailwindcss/plugin");

module.exports = {
  content: [
    "../lib/*_web.ex",
    "../lib/*_web/**/*.*ex",
    "./js/**/*.js",
    "../../../deps/petal_components/**/*.*ex",
    "../../petal_framework/**/*.*ex",
  ],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        primary: colors.blue,
        secondary: colors.pink,
        success: colors.green,
        danger: colors.red,
        warning: colors.yellow,
        info: colors.sky,
        gray: colors.slate,
      },
    },
  },
  plugins: [
  ...

You can pick any two colors from Taildwind's comprehensive list. Or if you want to go custom, you can try out this site.

In our case, we'll go for the colors "amber" and "slate":

tailwind.config.js
...
  theme: {
    extend: {
      colors: {
        primary: colors.amber,
        secondary: colors.slate,
        ...  
...

If you refresh your landing page, you'll notice the color change.

Public-facing pages

Landing page

The landing page consists of content from two core files:

  • lib/remindly_web/components/landing_page_components.ex - a set of components like <.hero>, <.features>, <.header> etc. The end goal of Petal Pro is to have a comprehensive set of landing page components that can be used as building blocks for your landing page. Ideally, you won't modify these too much but will use them just like Petal Components. For now, you can modify them as you wish.

  • /lib/remindly_web/controllers/page_html/landing_page.html.heex - a template file that uses the components from landing_page.ex

To keep this tutorial moving quickly, we won't do much design work on the landing page - if you open up landing_page.html.heex we can strip it down to merely a hero component:

landing_page.html.heex
<% max_width = "xl" %>

<LandingPage.hero
  image_src={~p"/images/landing_page/hero.svg"}
  logo_cloud_title={gettext("Trusted by brands all over the world")}
  max_width={max_width}
>
  <:title>
    <span><%= gettext("Always be") %></span>
    <span class="text-primary-600">
      <%= gettext("reminded") %>
    </span>
    <span><%= gettext("to do things") %>.</span>
  </:title>
  <:action_buttons>
    <.button
      label={gettext("Get started")}
      link_type="a"
      color="primary"
      to={~p"/auth/signup"}
      size="lg"
    />

    <.button
      label={gettext("Guide")}
      link_type="a"
      color="white"
      to="https://docs.petal.build"
      size="lg"
    />
  </:action_buttons>
  <:description>
    <%= gettext(
      "To be a functioning adult you need to be reminded to do adult-like tasks, like getting a hair cut or going to the dentist."
    ) %>
  </:description>
</LandingPage.hero>

<LandingPage.load_js_animations />

Now refresh your landing page and it should be a lot more simple. It is up to you how detailed you want to be with your landing page.

The reminders page

Now that the public-facing side of things is looking good enough, we can work on the app itself.

When a user signs in we want them to see a list of their reminders that they can check off or delete, with a button to create new ones. Normally, this would be a job for a Phoenix generator - luckily Petal Pro has its own version of the mix phx.gen.live generator - mix petal.gen.live. Our generator produces the same files as the Phoenix one, but the resulting HTML will use Tailwind and Petal Components so we don't have to worry about styling.

Using petal.gen.live

Let's run the generator to create the following schema:

mix petal.gen.live Reminders Reminder reminders label:string due_date:date is_done:boolean user_id:references:users

The command will output the routes needed to make the new pages work. Highlight them and just copy them to your clipboard for now.

Let's run the migration the generator just created:

mix ecto.migrate

Now we need to add these routes to the router. We can search for the text "Add live authenticated routes here".

Let's paste the routes we copied from the generator command below that line.

Petal Pro defaults to scoping authenticated routes within the β€œ/app” namespace. To continue this naming convention we need to edit the routes within the generated code. To do this you can do a "find and replace all" within the reminder_live folder.

Replace ~p"/reminders with ~p"/app/reminders.

Since it's in a protected area, we'll have to sign in before reaching this new page. Click the sign-in button in the top right dropdown:

We have an admin user seeded, but let's sign up as a new user. Click "Continue with passwordless":

Type in any email for now.

Now check your logs for a pin code:

After submitting the pin code you have a new user! No need for passwords.

Upon your first sign in you will be greeted with an onboarding screen.

Showing this screen is determined by the user field user.is_onboarded.

All new users have this defaulted to false, and thus will see this screen thanks to the OnboardingPlug that redirects to this page if current_user.is_onboarded == false.

Over time you might add more onboarding fields like "Where did you hear about us?" or "How many people are in your company?".

Onboarding can be switched off by removing the OnboardingPlug from all pipelines in the router. There is further explanation for this in onboarding_plug.ex

Just hit submit to get through the onboarding and you will then be shown a dashboard that we will ignore.

Instead, manually navigate to "http://localhost:4000/app/reminders" to see the newly created CRUD functionality.

You'll notice there is no layout. This is because we have to add that ourselves. We'll do that in a little bit. Firstly, let's get it running properly.

Assigning reminders to the current user

One issue with the generator is that it doesn't handle foreign keys very well. Currently, the reminders page shows reminders from ALL users. In fact, the reminder.user_id is not even being set when creating a new reminder, so they're not owned by anyone - every user who signs in will see them. We need to make it so everything is scoped to the currently signed-in user:

  • the index page table should only show the current users' reminders

  • when you create a new reminder, it should set the user_id to the current user's id

  • the show page should not be allowed to see other user's reminders

Firstly, there are a couple of problems with the schema file:

Let's fix those up:

reminder.ex
defmodule Remindly.Reminders.Reminder do
  use Ecto.Schema
  import Ecto.Changeset

  schema "reminders" do
    field :due_date, :date
    field :is_done, :boolean, default: false
    field :label, :string

    belongs_to :user, Remindly.Accounts.User

    timestamps()
  end

  @doc false
  def changeset(reminder, attrs) do
    reminder
    |> cast(attrs, [:label, :due_date, :is_done, :user_id])
    |> validate_required([:label, :due_date, :is_done, :user_id])
  end
end

Note that we've added :user_id to the validate_required function too, as we always want a reminder to belong to someone.

The reminder schema now knows it belongs to a user, but we still have to let the user schema know that it "has_many" reminders. Let's add that to the user schema file:

user.ex
defmodule Remindly.Accounts.User do
  ...

  schema "users" do
    # ...other fields omitted for brevity

    many_to_many :orgs, Org, join_through: "orgs_memberships", unique: true    
    has_one :customer, Customer

    # Add the line below:
    has_many :reminders, Remindly.Reminders.Reminder

    timestamps()
  end
  
  ...
end

Now go to index.ex in your reminder_live folder and let's modify the `assign_reminders` function:

index.ex
defmodule RemindlyWeb.ReminderLive.Index do
  ...

  defp assign_reminders(socket, params) do
    starting_query = Ecto.assoc(socket.assigns.current_user, :reminders)
    {reminders, meta} = DataTable.search(starting_query, params, @data_table_opts)
    assign(socket, reminders: reminders, meta: meta)
  end
  
  ...
end

Notice how the starting_query has changed? It now doesn't just fetch all reminders, but only the ones that belong to the signed-in user.

Great, the association is now complete. Now let's make it so that when you create a new reminder, the reminder.user_id gets populated with the signed-in user's id.

The code for creating the reminder is located in lib/remindly_web/live/reminder_live/form_component.ex :

form_component.ex
defmodule RemindlyWeb.ReminderLive.FormComponent do
  ...

  defp save_reminder(socket, :new, reminder_params) do
    case Reminders.create_reminder(reminder_params) do
      {:ok, _reminder} ->
        {:noreply,
         socket
         |> put_flash(:info, "Reminder created successfully")
         |> push_navigate(to: socket.assigns.return_to)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
  
  ...
end

Let's modify this to ensure the :user_id is set in the params:

defmodule RemindlyWeb.ReminderLive.FormComponent do
  ...

  defp save_reminder(socket, :new, reminder_params) do
    reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)
    
    case Reminders.create_reminder(reminder_params) do
      {:ok, _reminder} ->
        {:noreply,
         socket
         |> put_flash(:info, "Reminder created successfully")
         |> push_navigate(to: socket.assigns.return_to)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
  
  ...
end

Now when you try and create a new reminder, unfortunately, you get an error:

The current_user isn't in socket.assigns. This can be confusing because it is usually in the socket's assigns thanks to Petal Pro setting it in user_on_mount_hooks.ex.

The reason it isn't in this case is that form_component.ex is a live_component, not a live_view. Thus it has its own state and it's up to us to pass any assigns into it. We can see in index.html.heex the assigns being passed into it:

index.html.heex
<%= if @live_action in [:new, :edit] do %>
  <.modal title={@page_title}>
    <.live_component
      module={RemindlyWeb.ReminderLive.FormComponent}
      id={@reminder.id || :new}
      action={@live_action}
      reminder={@reminder}
      return_to={current_index_path(@index_params)}
    />
  </.modal>
<% end %>

So all we need to do is pass in the current_user to this live_component function:

index.html.heex
```phoenix-heex
  <%= if @live_action in [:new, :edit] do %>
    <.modal title={@page_title}>
      <.live_component
        module={RemindlyWeb.ReminderLive.FormComponent}
        id={@reminder.id || :new}
        action={@live_action}
        reminder={@reminder}
        return_to={current_index_path(@index_params)}
        current_user={@current_user}
      />
    </.modal>
  <% end %>
```

And now creating a new reminder works correctly 🎊.

The next problem is that in some browsers the date picker starts too far in the past - ideally, it'll show today's date. So let's add a default date for the form. This also will be done in the RemindlyWeb.ReminderLive.Index live view (index.ex). We can modify the existing function, so it looks like this:

reminder_live/index.ex
defmodule RemindlyWeb.ReminderLive.Index do
  ...
  
  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Reminder")
    |> assign(:reminder, %Reminder{due_date: Timex.now() |> Timex.to_date()})
  end
  
  ...
end

We use the Timex library (included in Petal Pro) to get today's date. Now the default is set correctly when creating new reminders (although since we're using UTC time, it may be 1 day off depending on where you live, but good enough for this tutorial).

Adding a "Done" checkbox

The "Is done" column with true or false is a bit ugly.

Let's move it to the front column and make it a checkbox. Working in the file index.html.heex, let's begin by rearranging the headers:

index.html.heex
```phoenix-heex
<.data_table :if={@index_params} meta={@meta} items={@reminders}>
    <:if_empty>No reminders found</:if_empty>
    <:col field={:is_done} sortable label="Done?" />
    <:col field={:label} sortable />
    <:col field={:due_date} sortable />
```

Next, we'll add an icon in the first cell that shows whether the task is done or not.

index.html.heex
```phoenix-heex
<.data_table :if={@index_params} meta={@meta} items={@reminders}>
    <:if_empty>No reminders found</:if_empty>
    <:col :let={reminder} field={:is_done} sortable label="Done?">
      <div class="cursor-pointer" phx-click="toggle_reminder" phx-value-id={reminder.id}>
        <.icon
          name={if reminder.is_done, do: :check, else: :x_mark}
          class="w-5 h-5"
        />
      </div>
    </:col>
    <:col field={:label} sortable />
    ...
```

Here we use :let={reminder} to expose the reminder variable to the inside of the :col slot. If the user clicks the icon, it calls the toggle_reminder event.

The data table component

Note that we are using the Petal Framework "Data table" component. You can dig into how this component works by opening it in your editor with:

code ./deps/petal_framework/lib/petal_framework/web/components/data_table/data_table.ex

Petal Framework is a set of advanced components that complements Petal Pro. You can request access to the repo on Discord if you don't have access already. We welcome PRs.

Let's create the event handler for "toggle_reminder". Add this event handler to the index.ex file:

index.ex
@impl true
def handle_event("toggle_reminder", %{"id" => reminder_id}, socket) do
  reminder = Reminders.get_reminder!(reminder_id)

  case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
    {:ok, _reminder} ->
      {:noreply, assign_reminders(socket, socket.assigns.index_params)}

    {:error, _changeset} ->
      {:noreply, put_flash(socket, :error, "Something went wrong.")}
  end
end

Now our checkbox is in sync. Live view works so fast that you can barely notice it's not a Single Page App. However, this won't always be the case once if someone is a long way from the server and has some latency.

With LiveView you can simulate latency. If you look in app.js you'll find a comment with a command to run the in the browser console. This will add 1 second of latency: liveSocket.enableLatencySim(1000). Run this in your browser console to see the lag.

LiveView adds classes when it's performing some kind of communication with the server. Phoenix helpfully adds a plugin to your tailwind.config.js file that allows you to target these classes:

Let's make use of this and add a spinner while waiting to hear back from the server.

index.html.heex
<:col :let={reminder} field={:is_done} sortable label="Done?">
  <div class="cursor-pointer" phx-click="toggle_reminder" phx-value-id={reminder.id}>
    <.icon
      name={if reminder.is_done, do: :check, else: :x_mark}
      class="w-5 h-5 phx-click-loading:hidden"
    />
    <.spinner class="hidden phx-click-loading:block" />
  </div>
</:col>

With that working correctly, we can disable the latency in the browser console: liveSocket.disableLatencySim().

Adding the layout

Now that our table is working nicely, let's add the layout back in.

index.html.heex
<.layout current_page={:reminders} current_user={@current_user} type="sidebar">
  <!-- Your existing code -->
</.layout>

And do the same with show.html.heex. The problem now is the sidebar menu doesn't show a link to the reminders page. Let's change that.

Adding a menu item

In Petal Pro we try to keep our menu items all following the same data structure (name, label, path, and icon, which refers to a Heroicon). Menu items can be constructed on the fly, but if it's commonly used we can store them in menus.ex. This file is like a small database for menu items. It also can omit menu items if a user doesn't have the right privileges (eg. admin menu items).

Each menu item has a function that looks like this:

def get_link(:dashboard, _current_user) do
  %{
    name: :dashboard,
    label: gettext("Dashboard"),
    path: ~p"/app",
    icon: :template
  }
end

The icon is passed directly to the <.icon /> component from Petal Components.

You can browse Heroicons and when you've found the one you like, just write the atom version of it. For example, the icon "bookmark-alt" would be :bookmark_alt.

From these function definitions, we can construct menus. There are two core menus - the main menu, which appears on this side panel, and also the user menu, which is in this dropdown.

Since we want to modify the sidebar, we want to change the main menu for a signed-in user. Let's replace :dashboard and :orgs with :reminders:

menus.ex
defmodule RemindlyWeb.Menus do
  ...
  
  # Signed in main menu
  def main_menu_items(current_user),
    do:
      build_menu(
        [
          :reminders
        ],
        current_user
      )
  
  ...
end

Lets also rename :dashboard with :reminders in the user menu:

menus.ex
defmodule RemindlyWeb.Menus do
  ...
  
  # Signed in user menu
  def user_menu_items(current_user), do: build_menu([:reminders, :settings, :admin, :dev, :sign_out], current_user)

  ...
end

However, this won't work yet as we still need to write a definition function for :reminders. Scroll down the file until you see the get_link/2 function for :dashboard. Let's overwrite this function and replace it with our reminders menu item:

menus.ex
defmodule RemindlyWeb.Menus do
  ...
  
  # Replace the get_link(:dashboard, ...) function with this:
  def get_link(:reminders = name, _current_user) do
    %{
      name: name,
      label: gettext("Reminders"),
      path: ~p"/app/reminders",
      icon: :clock
    }
  end
  
  ...
end

Next, we want this new route to be our kind of "home" route for signed-in users (eg. redirect here after being signed in, or when you click the logo in a layout). To set this, go to helpers.ex and look for the home_path/2 function:

helpers.ex
  def home_path(nil), do: "/"
  def home_path(_current_user), do: ~p"/app"

Here we can simply add the path of the new menu item:

helpers.ex
  def home_path(nil), do: "/"
  def home_path(_current_user), do: ~p"/app/reminders"

Now refresh the page and we can see the sidebar has been updated!

User activity logging

We've found that at some point in building a web application, you need to implement some kind of logging functionality. You or your client will want to see some stats on how your users are using your web application.

Petal Pro puts logging at the forefront and encourages you to log as much as you can. By default, it will store logs in your Postgres database, but there's no reason why you can't shoot them off to a 3rd party provider.

To showcase Petal Pro logging, we'll create a log every time a user creates a reminder and toggles it to "done". If you look in the file log.ex, you will see we have a list of actions that can be logged. Let's add our new log actions:

log.ex
defmodule Remindly.Logs.Log do
  ...

  @action_options [
    # ... other logs omitted. Add the two below:
    "create_reminder", 
    "complete_reminder"
  ]
  
  ...

Now we can do the actual logging. Go to form_component.ex and add a log in the create action.

form_component.ex
defp save_reminder(socket, :new, reminder_params) do
  reminder_params = Map.put(reminder_params, "user_id", socket.assigns.current_user.id)

  case Reminders.create_reminder(reminder_params) do
    {:ok, reminder} ->
      Remindly.Logs.log("create_reminder", %{
        user: socket.assigns.current_user,
        metadata: %{
          reminder_id: reminder.id,
          reminder_label: reminder.label
        }
      })

      {:noreply,
       socket
       |> put_flash(:info, "Reminder created successfully")
       |> push_navigate(to: socket.assigns.return_to)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, changeset: changeset)}
  end
end

You will see we're adding some metadata. It is up to you whether or not for new tables like "reminders" you create a migration and add a foreign key to the logs table (log.reminder_id). To keep it simple we can make use of a JSONB column on the logs table called metadata (JSONB is better than a JSON column as it allows for faster searching and even indexing). In this column, we can store any kind of map we want. We'll store the ID and label of the reminder here so we can track it if need be.

Now create a new reminder and we'll check that our logs have been updated. Logs are in the admin section. But to be able to see them you need to be admin.

When we seeded the project we added an admin user. You can sign out then sign back in with these credentials:

Email: admin@example.com

Password: password

Once signed in as admin try clicking the user menu (your avatar up the top right), and select "Admin".

Then click logs on the sidebar menu. Here you should see a new log for creating a reminder.

Let's do another log for when we complete a reminder. We'll need to modify our toggle_reminder function in index.ex.

index.ex
@impl true
  def handle_event("toggle_reminder", %{"id" => reminder_id}, socket) do
    reminder = Reminders.get_reminder!(reminder_id)

    case Reminders.update_reminder(reminder, %{is_done: !reminder.is_done}) do
      {:ok, reminder} ->
        if reminder.is_done do
          Remindly.Logs.log("complete_reminder", %{
            user: socket.assigns.current_user,
            metadata: %{
              reminder_id: reminder.id,
              reminder_label: reminder.label
            }
          })
        end
        
        {:noreply, assign_reminders(socket, socket.assigns.index_params)}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, "Something went wrong.")}
    end
  end

Now, when we toggle a reminder to "done", a new log will appear.

Live logs

You might have noticed a toggle for "Live logs" on the logs screen. If you turn this on, you will actually get a live feed of what's happening in your web app. You can try it out by opening two browser windows side by side: one on logs with "Live logs" checked, and one on the reminders screen. If you toggle the reminder on and off, you will see the logs appearing on the admin screen in real-time:

Note that if it isn't working you might have to restart your server.

Reminder emails

Next, we need to implement an email reminder that gets sent the day a reminder is overdue. If we head to "Dev", and then click on "Email templates" we can see a list of our transactional emails.

We want to create a new one and show it here so we can visualise it while we develop it. Before we do, let's do a quick overview of how emails work in Petal Pro. Open up email.ex and you will see a list of functions that generate Swoosh email structs. Eg:

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:

  Remindly.Email.confirm_register_email(user.email, url)
  |> Remindly.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_notifer.ex functions like controller actions.

So our steps will be:

  1. Create the function to generate a Swoosh struct in email.ex

  2. Ensure it looks good by creating a new action in email_testing_controller.ex that simply renders the html_body value of the struct

  3. Create a new user_notifier.ex function for use in our application

  4. Create a CRON job that runs once a day that finds overdue reminders and emails the user using the user_notifier.ex function

Designing the email

In email.ex, let's create a function for our new email:

email.ex
defmodule Remindly.Email do
  ...
  
  def reminder(email, reminder, reminder_url) do
    base_email()
    |> to(email)
    |> subject("Reminder!")
    |> render_body("reminder.html", %{reminder: reminder, url: reminder_url})
    |> premail()
  end
  
  ...
end

Now it expects a template reminder.html.heex. Let's add one to the /lib/remindly_web/templates/email folder:

reminder.html.heex
<h1>Reminder</h1>

<p><%= @reminder.label %></p>

<EmailComponents.button_centered to={@url}>
  View
</EmailComponents.button_centered>

Emails are notoriously hard to code and work across the myriad of email providers, so we created a template and included some components to help with the basics like a centered button or gap. See email_components.ex for these.

Now we can try seeing how it looks. Open up email_testing_controller.ex and do two things:

  1. Append "reminder" to the end of @email_templates

  2. Create a new generate_email/2 function for our new email

defmodule RemindlyWeb.EmailTestingController do
  ...
  
  @email_templates [
    "template",
    "register_confirm_email",
    "reset_password",
    "change_email",
    "org_invitation",
    "passwordless_pin",
    "reminder"
  ]
  
  defp generate_email("reminder", current_user) do
    reminder = %Remindly.Reminders.Reminder{
      user_id: current_user.id,
      label: "Do the washing.",
      due_date: Timex.now() |> Timex.to_date()
    }

    Email.reminder(current_user.email, reminder, "/")
  end
  
  ...
end

Now if you refresh the "Email templates" page you can see your new email design:

Note that if you missed it earlier - since emails can be opened in all kinds of email clients, the logo must be hosted online somewhere.

I recommend uploading your logo in png format to some kind of image hosting you like. We personally like Cloudinary so let's chuck our logo up there and then paste the URL into our config file.

config.exs
config :remindly,
  app_name: "Remindly",
  business_name: "Remindly Pty Ltd",
  support_email: "matt@petal.build",
  mailer_default_from_name: "Remindly",
  mailer_default_from_email: "matt@petal.build",
  logo_url_for_emails: "https://res.cloudinary.com/wickedsites/image/upload/v1646277219/petal_marketing/remindly_logo_tsvksl.png",
  seo_description: "Reminder app",
  css_theme_default: "dark"

Great, now that the email is ready to go we can implement the user_notifier.ex function that accepts a reminder Ecto struct as a parameter and sends the reminder email for us:

defmodule Remindly.Accounts.UserNotifier do
  ...
  
  def deliver_reminder(reminder) do
    reminder = Remindly.Repo.preload(reminder, :user)
    url = RemindlyWeb.Router.Helpers.reminder_show_url(RemindlyWeb.Endpoint, :show, reminder)

    Email.reminder(reminder.user.email, reminder, url)
    |> deliver()
  end
  
  ...
end

Now we can test a sent email in IEX:

iex -S mix phx.server
reminder = Repo.last(Remindly.Reminders.Reminder)
user = Repo.last(User)
Remindly.Accounts.UserNotifier.deliver_reminder(reminder)

Note that if you get an error, you may need to recompile with recompile in iex

Click on "Sent emails" in the sidebar to see that it worked properly. If we cmd-click the button it should take us to the right reminder.

Sending the email for overdue reminders

With an email ready to be sent to people who have been lazy, let's create a CRON job to send off the emails. We use Oban for jobs, which supports a CRON schedule (CRON just means a time schedule). Here's what we'll do:

  1. We create a worker Remindly.Workers.ReminderWorker, which has a function perform/1 that will find overdue reminders and send the reminder emails

  2. We tell Oban to run this worker once per day

Reminder worker

Petal Pro comes with an example worker you can check out (example_worker.ex). In the same folder, let's create reminder_worker.ex:

And then enter this code snippet:

reminder_worker.ex
defmodule Remindly.Workers.ReminderWorker do
  @moduledoc """
  Run this with:
  Oban.insert(Remindly.Workers.ReminderWorker.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("ReminderWorker: Sending reminders for #{today}")

    # TODO: Find all overdue reminders and send the user an email

    :ok
  end
end

Now we have a skeleton worker we can work with. Try testing it out in IEX:

Oban.insert(Remindly.Workers.ReminderWorker.new(%{}))

Note you may need to restart your server if you get an error here.

Now we need to find all the overdue reminders. This is a job for the context file reminders.ex. In there we can add the following function:

defmodule Remindly.Reminders do
  ...
  
  def list_overdue_reminders do
    today = Timex.now() |> Timex.to_date()

    Repo.all(
      from r in Reminder,
        where: r.due_date < ^today,
        where: r.is_done == false
    )
  end
  
  ...

Normally we'd write a test for this, but for brevity, we'll assume it works correctly (or you can test it in IEX if you like).

Back to the worker, let's finish it off:

reminder_worker.ex
defmodule Remindly.Workers.ReminderWorker do
  @moduledoc """
  Run this with:
  Oban.insert(Remindly.Workers.ReminderWorker.new(%{}))
  """
  use Oban.Worker, queue: :default
  alias Remindly.{Repo, Reminders}
  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{} = _job) do
    today = Timex.now() |> Timex.to_date()
    Logger.info("ReminderWorker: Sending reminders for #{today}")

    Reminders.list_overdue_reminders()
    |> Repo.preload(:user)
    |> Enum.each(fn reminder ->
      Logger.info("Reminding #{reminder.user.name} to #{reminder.label}")
      Remindly.Accounts.UserNotifier.deliver_reminder(reminder)
    end)

    :ok
  end
end

We can test it by setting one of our reminders in the past (making sure it's unchecked) and then running the worker in IEX again.

You can double-check by also looking at the "Sent emails" page again.

CRON job

Now that we know our worker works, we want to schedule it to run every day. This is a simple task thanks to Oban - just modify config.exs:

...

config :remindly, Oban,
  repo: Remindly.Repo,
  queues: [default: 5],
  plugins: [
    {Oban.Plugins.Pruner, max_age: (3600 * 24)},
    {Oban.Plugins.Cron,
      crontab: [
        {"@daily", Remindly.Workers.ReminderWorker}
        # {"* * * * *", Remindly.EveryMinuteWorker},
        # {"0 * * * *", Remindly.EveryHourWorker},
        # {"0 */6 * * *", Remindly.EverySixHoursWorker},
        # {"0 0 * * SUN", Remindly.EverySundayWorker},
        # More examples: https://crontab.guru/examples.html
      ]}
  ]
  
...

To keep it simple we'll use the @daily code for scheduling. If you want to fine tune it to the exact minute, you can use the usual CRON syntax (you might need a website to help you generate it).

And that's it! Our job will now run daily.

Deployment with Fly.io

We have found Fly.io to be the best combination of cheap and easy. Petal Pro has been set up for users to quickly deploy on Fly.io's servers.

If you haven't already, download the Fly.io CLI. Then you will need to register or sign in.

Once signed in, you can create a new project with:

fly launch

New: fly may prompt you with with a series of default settings and ask if you want to tweak them. Hit Y and it will open your browser with a UI where you can pick/choose settings.

  • Call the app remindly or something similar.

  • Under Database, choose "Fly Postgres", as we'll need that.

  • I'll go with the cheapest configuration for a server - not sure this reminders app will catch on.

  • You can leave Redis as none.

  • Hit Confirm.

At this point, it may try to generate and deploy the app, but will fail, as we haven't told Fly to add our "petal" repo. See the section below on what to do next and also for how to enable email sending.

Building assets

In the generated Dockerfile you may need to add a small change to properly compile the assets.

Change:

RUN mix assets.deploy

to:

RUN mix assets.setup
RUN mix assets.deploy

Email sending

To be able to register/sign in, we'll need to ensure email is set up and we'll need a service to send our emails out. We've found that the simplest and cheapest solution is Amazon SES, and so Petal defaults to using this. Look in runtime.exs to see the setup:

config :remindly, Remindly.Mailer,
    adapter: Swoosh.Adapters.AmazonSES,
    region: System.get_env("AWS_REGION"),
    access_key: System.get_env("AWS_ACCESS_KEY"),
    secret: System.get_env("AWS_SECRET")

We don't really use Amazon for much else, but its email service is cheap and the emails don't get sent to spam as easily as other services we've tried (cough cough Sendgrid).

Setting up Amazon SES is beyond the scope of this tutorial. You can read their docs here to set it up. The end result should be you are able to provide the following secrets that we'll provide to our production server:

fly secrets set AWS_ACCESS_KEY="xxx" AWS_SECRET="xxx" AWS_REGION="xxx"

If you don't want to use SES you can switch to a different Swoosh adapter.

Telling Fly to add our "petal" repo

Since Petal Framework is not coming from hex.pm, we need Fly to know to add our Petal registry.

After running the fly launch command above, Fly has generated a dockerfile in the root of our project.

Open Dockerfile and search for this bit:

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

Before those commands are run, we need to add the Petal repo. So, above this block of code you want to add the Petal repo:

# Add 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)

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

You may also need to add RUN mix assets.setup to the compile assets block of code.

# compile assets
RUN mix assets.setup
RUN mix assets.deploy

Finally, we can run fly deploy --build-secret PETAL_LICENSE_KEY=<your key>

You can see your key in the install instructions on Petal (see Step 1 and copy the key written after "--auth-key").

If deployment fails here, you may need to add a payment method to your account as Fly only allows 1 machine per app.

After deploying you can run fly open to see it in your browser. If you've made it this far, congratulations! You've just gone from nothing to production. Obviously, this app needs some touching up, but it gives you an idea of how to do things. We look forward to seeing what people create with Petal Pro.

If you have any feedback, head here to see how to get in touch.

Last updated