Phoenix 1.7.11 with LiveView 0.20.12 - Using Authorization

Article 2 - Exploring Phoenix Authorization Usages

In this article we start with the code from Phoenix 1.7.11 with LiveView 0.20.9 - Authorization Intro. The starting code can be found at: https://github.com/btihen-dev/phoenix_authorize

This code can be found at: https://github.com/btihen-dev/auth_buzz

Article Series

Getting started

Before we get started let’s update our packages:

mix deps.update --all

Add Chat Topics

These will eventually only be available to change by “Admins” - but we won’t start with this.

We will start with making topics that can only be created and removed by Admins Then we will add messages that any user can add to a topic

Let’s start by generating our new LiveViews and Models

mix phx.gen.live Topics Topic topics title:string --web Buzz

    scope "/buzz", AuthorizeWeb.Buzz, as: :buzz do
      pipe_through :browser
      ...

      live "/topics", TopicLive.Index, :index
      live "/topics/new", TopicLive.Index, :new
      live "/topics/:id/edit", TopicLive.Index, :edit

      live "/topics/:id", TopicLive.Show, :show
      live "/topics/:id/show/edit", TopicLive.Show, :edit
    end

Lets move all these new files into the namespace of Buzz:

mkdir lib/authorize/buzz
mkdir test/authorize/buzz
mkdir test/support/fixtures/buzz

mv lib/authorize/topic* lib/authorize/buzz/.
mv test/authorize/topic* test/authorize/buzz/.
mv test/support/fixtures/topic* test/support/fixtures/buzz/.

Lets update the Module names with the Buzz namespace too: Authorize.Topics to become Authorize.Buzz.Topics

now lets migrate and run tests:

mix ecto.migrate
mix test

now go to: http://localhost:4040/buzz/topics and be sure you can create, delete topics, etc.

now we can add to the seeds file:

# priv/repo/seeds.exs
alias Authorize.Buzz.Topics

topics = [
  %{title: "cats"},
  %{title: "dogs"}
]
Enum.map(topics, fn topic -> Topics.create_topic(topic) end)

cool let’s snapshot this:

git add .
git commit -m "add chat topics"

Manage Topics (Admins only)

let’s move the live-topics to admin area

mv lib/authorize_web/live/buzz/topic_live lib/authorize_web/live/admin/.
mv test/authorize_web/live/buzz/topic* test/authorize_web/live/admin/.

Replace all AuthorizeWeb.Buzz.TopicLive with AuthorizeWeb.Admin.TopicLive

now replace all ~p"/buzz/topics with ~p"/admin/topics

now lets update the routes and put our new routes in the Admin the section - so:

# lib/authorize_web/router.ex

  scope "/admin", AuthorizeWeb.Admin, as: :admin do
    pipe_through [:browser, :require_authenticated_user, :require_admin_user]

    live_session :admin_live,
      on_mount: [
        {AuthorizeWeb.Access.UserAuth, :ensure_authenticated},
        {AuthorizeWeb.Access.UserAuth, :ensure_admin}
      ] do
      live("/admin_roles", AdminRolesLive, :index)
    end
  end

becomes:

# lib/authorize_web/router.ex

  scope "/admin", AuthorizeWeb.Admin, as: :admin do
    pipe_through [:browser, :require_authenticated_user, :require_admin_user]

    live_session :admin_live,
      on_mount: [
        {AuthorizeWeb.Access.UserAuth, :ensure_authenticated},
        {AuthorizeWeb.Access.UserAuth, :ensure_admin}
      ] do
      live("/admin_roles", AdminRolesLive, :index)

      # Add other live routes here that require the same authentication
      live "/topics", TopicLive.Index, :index
      live "/topics/new", TopicLive.Index, :new
      live "/topics/:id/edit", TopicLive.Index, :edit

      live "/topics/:id", TopicLive.Show, :show
      live "/topics/:id/show/edit", TopicLive.Show, :edit
    end
  end

and of course remove the routes:

# lib/authorize_web/router.ex
  scope "/buzz", AuthorizeWeb.Buzz, as: :buzz do
    pipe_through :browser

    live "/topics", TopicLive.Index, :index
    live "/topics/new", TopicLive.Index, :new
    live "/topics/:id/edit", TopicLive.Index, :edit

    live "/topics/:id", TopicLive.Show, :show
    live "/topics/:id/show/edit", TopicLive.Show, :edit
  end

now only admins should be able to manage topics - you can test with an incognito browser session.

lets run our tests;

mix test

oops - we get lots of errors like:

  1) test Index updates topic in listing (AuthorizeWeb.Buzz.TopicLiveTest)
     test/authorize_web/live/admin/topic_live_test.exs:49
     ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/access/users/log_in", flash: %{"error" => "You must log in to access this page."}}}}
     code: {:ok, index_live, _html} = live(conn, ~p"/admin/topics")
     stacktrace:
       test/authorize_web/live/admin/topic_live_test.exs:50: (test)

Now we need to provide an admin user now in the tests: test/authorize_web/live/admin/topic_live_test.exs

git commit -am "topics are managed by admins

Topic Members (Admin only)

We need the data, lets look at the following hex mix phx.gen page

but no live pages since we will integrate this using phx.gen.schema - this builds a migration, schema, but no live page nor context (we will write a simple context on our own) but we could have used mix phx.gen.context TopicMember ... and deleted unnecessary code and then had tests automatically generated. .

mix phx.gen.schema TopicMember topic_members \
    topic_id:references:topics member_id:references:users

now lets move lib/authorize/topic_member.ex to the buzz area.

mkdir lib/authorize/buzz/topic_members
mv lib/authorize/topic_member.ex lib/authorize/buzz/topic_members/.

and rename all Authorize.TopicMember to Authorize.Buzz.TopicMembers.TopicMember

now lets update the migration to only allow a user to be a member of a given topic ONCE - we need to add the line: create unique_index(:topic_members, [:topic_id, :member_id]) - we will leave the on-delete in this case alone.

# priv/repo/migrations/yyyymmddHHMMss_create_topic_members.exs
defmodule Authorize.Repo.Migrations.CreateTopicMembers do
  use Ecto.Migration

  def change do
    create table(:topic_members, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :topic_id, references(:topics, on_delete: :delete_all, type: :binary_id)
      add :member_id, references(:users, on_delete: :delete_all, type: :binary_id)

      timestamps(type: :utc_datetime)
    end

    create index(:topic_members, [:topic_id])
    create index(:topic_members, [:member_id])
    # add the following index to prevent duplicate entriess
    create unique_index(:topic_members, [:topic_id, :member_id]), name: :unique_topic_members
  end
end

now lets build our relationships - lets start with our new file Authorize.Buzz.TopicMembers.TopicMember and both the relationships and the unique constraint: let’s build associations by adding:

    belongs_to :topic, Topic, foreign_key: :topic_id
    belongs_to :member, User, foreign_key: :member_id

and let’s add a validation for our joint unique entry index by adding:

    |> unique_constraint(:topic_id, name: :unique_topic_members) # validates unique
    |> unique_constraint(:member_id, name: :unique_topic_members) # validates unique
# lib/authorize/buzz/topic_members/topic_member.ex
defmodule Authorize.Buzz.TopicMembers.TopicMember do
  use Ecto.Schema
  import Ecto.Changeset

  # simplify with aliases
  alias Authorize.Buzz.Topics.Topic
  alias Authorize.Core.Accounts.User

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "topic_members" do
    # field :topic_id, :binary_id
    # field :member_id, :binary_id
    belongs_to :topic, Topic, foreign_key: :topic_id
    belongs_to :member, User, foreign_key: :member_id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(topic_member, attrs) do
    topic_member
    |> cast(attrs, [:topic_id, :member_id])
    |> validate_required([:topic_id, :member_id])
    |> unique_constraint(:topic_id, name: :unique_topic_members) # validates unique
    |> unique_constraint(:member_id, name: :unique_topic_members) # validates unique
  end
end

Now we need to add relationships to Topics and Users by adding has-many relationships this would look like:

    has_many :topic_members, TopicMember
    has_many :members, through: [:topic_members, :user]

so now Topics looks like:

# lib/authorize/buzz/topics/topic.ex
defmodule Authorize.Buzz.Topics.Topic do
  use Ecto.Schema
  import Ecto.Changeset

  # new alias
  alias Authorize.Buzz.TopicMembers.TopicMember

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "topics" do
    field :title, :string

    # new code
    has_many :topic_members, TopicMember
    has_many :members, through: [:topic_members, :user]

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(topic, attrs) do
    topic
    |> cast(attrs, [:title])
    |> validate_required([:title])
  end
end

and for Users we add a similar, has_many association using:

    has_many :topic_members, TopicMember, foreign_key: :member_id
    has_many :topics, through: [:topic_members, :topic]

also if not yet done we should change:

  def admin?(user), do: "admin" in user.roles || user.email == "batman@example.com"

to

  def admin?(user), do: "admin" in user.roles

so it now looks like:

# lib/authorize/core/accounts/user.ex
defmodule Authorize.Core.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  # new alias
  alias Authorize.Buzz.TopicMembers.TopicMember

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
    field :roles, {:array, :string}, default: ["user"]

    # new association scode
    has_many :topic_members, TopicMember, foreign_key: :member_id
    has_many :topics, through: [:topic_members, :topic]

    timestamps(type: :utc_datetime)
  end
  # ...
end

Now lets stop and see if our tests still work.

mix ecto.migrate
mix test

Manage Topic Members in the Admin GUI

the goal is to be able to add a multi-select in the format that we can do something like the following in our topic form_component;

# lib/authorize_web/live/admin/topic_live/form_component.ex
        <.input field={@form[:title]} type="text" label="Title" />
        <!-- a multi-select of users to add to topics as members -->
        <select name="members[]" multiple id="members" class="form-control">
          <%= for user <- @users do %>
            <%= selected = user.id in Enum.map(@topic.members, & &1.id) %>
            <option value={user.id} selected={selected}><%= user.email %></option>
          <% end %>
        </select>
        <:actions>
          <.button phx-disable-with="Saving...">Save Topic</.button>
        </:actions>

but we need to do some prep-work first.

Let’s build the TopicMember context - let’s start with being able to list them:

# lib/authorize/buzz/topic_members.ex
defmodule Authorize.Buzz.TopicMembers do
  import Ecto.Query, warn: false
  alias Authorize.Repo

  alias Authorize.Buzz.TopicMembers.TopicMember

  def topic_members_by_id(id) do
    query = from(t in TopicMember, where: t.topic_id == ^id)
    Repo.all(query)
  end
end

Let’s update the Topic Context - lets start by preloading members when we call up topics:

now we can preload members (into our topics with) to change from:

  def list_topics, do: Repo.all(Topic)

  def get_topic!(id), do: Repo.get!(Topic, id)

to load (sequentially) all our has_many models:

  def list_topics do
    Repo.all(Topic)
    |> Repo.preload(:topic_members) # load first step (has many)
    |> Repo.preload(topic_members: :member) # load join table
    |> Repo.preload(:members) # finally load the model wanted!
  end

  def get_topic!(id) do
    Repo.get!(Topic, id)
    |> Repo.preload(:topic_members)
    |> Repo.preload(topic_members: :member)
    |> Repo.preload(:members)
  end

or more succinctly:

  def list_topics do
    Repo.all(Topic)
    |> Repo.preload([:topic_members, [topic_members: :member], :members])
  end

  def get_topic!(id) do
    Repo.get!(Topic, id)
    |> Repo.preload([:topic_members, [topic_members: :member], :members])
  end

lastly the update is a bit more complicated

  def update_topic(%Topic{} = topic, attrs) do
    topic
    |> Topic.changeset(attrs)
    |> Repo.update()
  end

to this. We could figure out what’s different, but that is complex. It is perhaps easier to delete the previous records and recreate a new records. (Ideally this would be done within a transaction - ecto multi - we can look at this later)

  def update_topic(%Topic{} = topic, attrs) do
    topic_members = TopicMembers.topic_members_by_id(topic.id)
    if is_list(topic_members) && !Enum.empty?(topic_members) do
      from(t in TopicMember, where: t.topic_id == ^topic.id) |> Repo.delete_all()
    end

    member_ids = Map.get(attrs, "member_ids") || []
    if is_list(member_ids) && !Enum.empty?(member_ids) do
      topic_members =
        Enum.map(member_ids, fn member_id ->
          now = DateTime.utc_now() |> DateTime.truncate(:second)
          %{topic_id: topic.id, member_id: member_id, inserted_at: now, updated_at: now}
        end)
      Repo.insert_all(TopicMember, topic_members)
    end

    topic
    |> Topic.changeset(attrs)
    |> Repo.update()
  end

so now the full module should look like:

# lib/authorize/buzz/topics.ex
defmodule Authorize.Buzz.Topics do
  import Ecto.Query, warn: false
  alias Authorize.Repo

  alias Authorize.Buzz.Topics.Topic
  alias Authorize.Buzz.TopicMembers
  alias Authorize.Buzz.TopicMembers.TopicMember

  def list_topics do
    Repo.all(Topic) # |> Repo.preload(:members)
    |> Repo.preload(:topic_members)
    |> Repo.preload(topic_members: :member)
    |> Repo.preload(:members)
    # |> Repo.preload([:topic_members, [topic_members: :member], :members])
  end

  def get_topic!(id) do
    Repo.get!(Topic, id)
    |> Repo.preload(:topic_members)
    |> Repo.preload(topic_members: :member)
    |> Repo.preload(:members)
    # |> Repo.preload([:topic_members, [topic_members: :member], :members])
    |> IO.inspect(label: "get_topic!")
  end

  def create_topic(attrs \\ %{}) do
    IO.inspect(attrs, label: "create_topic attrs")
    %Topic{}
    |> Topic.changeset(attrs)
    |> Repo.insert()
  end

  def update_topic(%Topic{} = topic, attrs) do
    topic_members = TopicMembers.topic_members_by_id(topic.id)
    if is_list(topic_members) && !Enum.empty?(topic_members) do
      from(t in TopicMember, where: t.topic_id == ^topic.id) |> Repo.delete_all()
    end

    member_ids = Map.get(attrs, "member_ids") || []
    if is_list(member_ids) && !Enum.empty?(member_ids) do
      topic_members =
        Enum.map(member_ids, fn member_id ->
          now = DateTime.utc_now() |> DateTime.truncate(:second)
          %{topic_id: topic.id, member_id: member_id, inserted_at: now, updated_at: now}
        end)
      Repo.insert_all(TopicMember, topic_members)
    end

    topic
    |> Topic.changeset(attrs)
    |> Repo.update()
  end

  def delete_topic(%Topic{} = topic) do
    Repo.delete(topic)
  end

  def change_topic(%Topic{} = topic, attrs \\ %{}) do
    Topic.changeset(topic, attrs)
  end
end

Now we need to add users to the index and show mount functions:

# lib/authorize_web/live/admin/topic_live/index.ex
defmodule AuthorizeWeb.Admin.TopicLive.Index do
  use AuthorizeWeb, :live_view

  alias Authorize.Buzz.Topics
  alias Authorize.Buzz.Topics.Topic
  # add this line
  alias Authorize.Admin.Authorized

  @impl true
  def mount(_params, _session, socket) do
    # add the next 2 lines
    users = Authorized.list_users()
    socket = assign(socket, :users, users)
    {:ok, stream(socket, :topics, Topics.list_topics())}
  end
  # ...
end

and show with;

# lib/authorize_web/live/admin/topic_live/show.ex
defmodule AuthorizeWeb.Admin.TopicLive.Show do
  use AuthorizeWeb, :live_view

  alias Authorize.Buzz.Topics
  alias Authorize.Admin.Authorized

  @impl true
  def mount(_params, _session, socket) do
    users = Authorized.list_users()
    socket = assign(socket, :users, users)
    {:ok, socket}
  end
  # ...
end

In order to pass the users we acquired in index.ex and show.ex we need to update index.html.heex and show.html.heex by adding users={@users} to the live_component parameters - so it would now look like:

# lib/authorize_web/live/admin/topic_live/index.html.heex
<!-- ... -->
<.modal :if={@live_action in [:new, :edit]}
  id="topic-modal" show on_cancel={JS.patch(~p"/admin/topics")}>
  <.live_component
    module={AuthorizeWeb.Admin.TopicLive.FormComponent}
    id={@topic.id || :new}
    title={@page_title}
    action={@live_action}
    topic={@topic}
    users={@users}
    patch={~p"/admin/topics"}
  />
</.modal>

and show changes too:

# lib/authorize_web/live/admin/topic_live/show.html.heex
<!-- ... -->
<.modal :if={@live_action == :edit}
  id="topic-modal" show on_cancel={JS.patch(~p"/admin/topics/#{@topic}")}>
  <.live_component
    module={AuthorizeWeb.Admin.TopicLive.FormComponent}
    id={@topic.id}
    title={@page_title}
    action={@live_action}
    topic={@topic}
    users={@users}
    patch={~p"/admin/topics/#{@topic}"}
  />
</.modal>

now we can finally add members to the topic form (with a multi-select) by changing from

         <.input field={@form[:title]} type="text" label="Title" />
         <:actions>
           <.button phx-disable-with="Saving...">Save Topic</.button>
         </:actions>

to

# lib/authorize_web/live/admin/topic_live/form_component.ex
        <.input field={@form[:title]} type="text" label="Title" />

        <!-- add a multi-select for members -->
        <select name="members[]" multiple id="members" class="form-control">
          <%= for user <- @users do %>
            <%= selected = user.id in Enum.map(@topic.members, & &1.id) %>
            <option value={user.id} selected={selected}><%= user.email %></option>
          <% end %>
        </select>

        <:actions>
          <.button phx-disable-with="Saving...">Save Topic</.button>
        </:actions>

finally we need to update the form handlers from:

# lib/authorize_web/live/admin/topic_live/form_component.ex
  def handle_event("validate", %{"topic" => params}, socket) do
    changeset =
      socket.assigns.topic
      |> Topics.change_topic(params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  def handle_event("save", %{"topic" => params}, socket) do
    save_topic(socket, socket.assigns.action, params)
  end
  # ...
end

to adding the following:

    topic_params = Map.get(params, "topic")
    member_ids = Map.get(params, "members") || []
    params = Map.put(topic_params, "member_ids", member_ids)

to collect the new params sent by the form - so it now looks like:

# lib/authorize_web/live/admin/topic_live/form_component.ex
  def handle_event("validate",  params, socket) do
    topic_params = Map.get(params, "topic")
    member_ids = Map.get(params, "members") || []
    params = Map.put(topic_params, "member_ids", member_ids)

    changeset =
      socket.assigns.topic
      |> Topics.change_topic(params)
      |> Map.put(:action, :validate)

     {:noreply, assign_form(socket, changeset)}
   end

  def handle_event("save", params, socket) do
    topic_params = Map.get(params, "topic")
    member_ids = Map.get(params, "members") || []
    params = Map.put(topic_params, "member_ids", member_ids)

    save_topic(socket, socket.assigns.action, params)
  end
  # ...
end

let’s ensure the that we can add and remove users from: http://localhost:4000/admin/topics/1/edit

mix test
git add .
git commit -m "add memmbership management to the message topics"s

Display Topic Members in Admin Panel

Now lets show a comma separated list of members in our topics index table - to do this we can add:

  <:col :let={{_id, topic}} label="Members' Emails">
    <%=
      topic.members
      |> Enum.map(& &1.email)
      |> Enum.join(", ")
    %>
  </:col>

so now our table looks more like:

# lib/authorize_web/live/admin/topic_live/index.html.heex
  # ...
  <:col :let={{_id, topic}} label="Title">
    <%= topic.title %>
  </:col>

  <:col :let={{_id, topic}} label="Members' Emails">
    <%=
      topic.members
      |> Enum.map(& &1.email)
      |> Enum.join(", ")
    %>
  </:col>
  # ...

and similarly in the show we will display a list with by adding:

<:item title="Members">
    <ul>
      <%= for member <- @topic.members do %>
        <li><%= member.email %></li>
      <% end %>
    </ul>
  </:item>

to the list component:

# lib/authorize_web/live/admin/topic_live/show.html.heex
# ...
<.list>
  <:item title="Title">
    <%= @topic.title %>
  </:item>

  <:item title="Members">
    <ul>
      <%= for member <- @topic.members do %>
        <li><%= member.email %></li>
      <% end %>
    </ul>
  </:item>
</.list>
# ...

now check by looking at: http://localhost/admin/topics and http://localhost:4040/admin/topics/

git add .
git commit -m "display topic members in admin panel

Admin Fixes

New

Now that we are are dependent on an Array of ‘members’ in our display, we need to preload members (even though empty) when we make new Topics. So in the topics context I will simplify this with a new_topic() method that looks like:

# lib/authorize/buzz/topics.ex

  def new_topic() do
    %Topic{}
    |> Repo.preload(:topic_members)
    |> Repo.preload([topic_members: :member])
    |> Repo.preload(:members)
  end

now I will adjust new apply_action in the admin index :new with the Topics.new_topic() function:

# lib/authorize_web/live/admin/topic_live/index.ex

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Topic")
    |> assign(:topic, Topics.new_topic())
  end

These fixes work, but we still need to manually reload the page afterwards to see the members, to fix this we need to ensure we have the new members loaded when we broadcast the changes we also need to update create_topic to save all members via topic members (like the update) and then load all members afterwards using get_topic!(topic.id) which reloads and does an eager load of all the members:

# lib/authorize/buzz/topics.ex
def create_topic(attrs \\ %{}) do
    topic_changeset =
      new_topic()
      |> Topic.changeset(attrs)
      |> Repo.insert()

    {:ok, topic} = topic_changeset
    member_ids = Map.get(attrs, "member_ids") || []
    if is_list(member_ids) && !Enum.empty?(member_ids) do
      topic_members =
        Enum.map(member_ids, fn member_id ->
          now = DateTime.utc_now() |> DateTime.truncate(:second)
          %{topic_id: topic.id, member_id: member_id, inserted_at: now, updated_at: now}
        end)
      Repo.insert_all(TopicMember, topic_members)
    end

    # reload since added members after initial save
    topic = get_topic!(topic.id)
    {:ok, topic}
  end

Update

you may also have noticed that we need to do a manual update after we update members, just like with the new we can fix the update broadcast by reloading all members before we broadcast the change using the same get_topic!(topic.id) - now update looks like:

# lib/authorize/buzz/topics.ex
 def update_topic(%Topic{} = topic, attrs) do
    topic
    |> Topic.changeset(attrs)
    |> Repo.update()

    # Delete all members
    topic_members = TopicMembers.topic_members_by_id(topic.id)
    if is_list(topic_members) && !Enum.empty?(topic_members) do
      from(t in TopicMember, where: t.topic_id == ^topic.id) |> Repo.delete_all()
    end

    # rebuild members with the new ones
    member_ids = Map.get(attrs, "member_ids") || []
    if is_list(member_ids) && !Enum.empty?(member_ids) do
      topic_members =
        Enum.map(member_ids, fn member_id ->
          now = DateTime.utc_now() |> DateTime.truncate(:second)
          %{topic_id: topic.id, member_id: member_id, inserted_at: now, updated_at: now}
        end)
      Repo.insert_all(TopicMember, topic_members)
    end

    # reload since added members after initial save
    topic = get_topic!(topic.id)
    {:ok, topic}
  end

Delete

We also need to add the delete strategy for delete to work when it has members so we need to update the schema with has_many :topic_members, TopicMember, on_delete: :delete_all so now the scheme looks like:

# lib/authorize/buzz/topics/topic.ex
defmodule Authorize.Buzz.Topics.Topic do
  use Ecto.Schema
  import Ecto.Changeset

  alias Authorize.Buzz.TopicMembers.TopicMember

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "topics" do
    field :title, :string

    has_many :topic_members, TopicMember, on_delete: :delete_all
    has_many :members, through: [:topic_members, :member]

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(topic, attrs) do
    topic
    |> cast(attrs, [:title])
    |> validate_required([:title])
  end
end
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature