Powered by AppSignal & Oban Pro

Trilha Phoenix · Fronteiras entre contextos

10_fronteiras_entre_contextos.livemd

Trilha Phoenix · Fronteiras entre contextos

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
       ]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
  o psql mostraria, registra migrations, faz requisições ao endpoint como um
  navegador faria (com cookies e formulários), confere os exercícios e guarda as
  perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo
  @contas Hello.Accounts
  @escopo Hello.Accounts.Scope
  @carrinho Hello.ShoppingCart
  @item Hello.ShoppingCart.CartItem
  @catalogo Hello.Catalog
  @vitrine Hello.Vitrine
  @desejos Hello.Desejos

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> escopo(conn) <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
  end

  defp escopo(conn) do
    case conn.assigns do
      %{current_scope: %{user: %{email: email}}} -> "**Logado como** `#{email}`\n\n"
      %{current_scope: nil} -> "**Ninguém logado** (`current_scope` é `nil`)\n\n"
      _ -> ""
    end
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    end)
    |> String.split("\n")
    |> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- formulários -------------------------------------------------------------

  @doc "Mostra os campos do primeiro formulário da página, como o navegador os enviaria."
  def formulario(quem, caminho) do
    {acao, campos, rotulos} = ler_formulario(quem, caminho)
    rotulo_de = Map.new(rotulos, fn {rotulo, nome} -> {nome, rotulo} end)
    linhas = Enum.map(campos, fn {nome, valor} -> "| `#{nome}` | #{rotulo_de[nome]} | `#{valor}` |" end)

    Kino.Markdown.new(
      "### O formulário de `#{caminho}`, visto por #{quem}\n\nO navegador envia `POST #{acao}` com estes campos:\n\n" <>
        Enum.join(["| Campo | Rótulo na tela | Valor |", "| --- | --- | --- |" | linhas], "\n")
    )
  end

  @doc """
  Preenche o primeiro formulário da página e o envia, como um clique no botão.
  `alteracoes` troca o valor dos campos, indicados pelo rótulo que aparece na tela
  (`"Metaprogramming Elixir" => "3"`) ou pelo nome do campo
  (`"cart[items][0][quantity]" => "3"`). Os outros campos vão com o valor que a
  página trouxe.
  """
  def enviar(quem, caminho, alteracoes \\ %{}) do
    {acao, campos, rotulos} = ler_formulario(quem, caminho)
    nomes = Map.new(campos)

    alteracoes =
      Map.new(alteracoes, fn {chave, valor} ->
        cond do
          Map.has_key?(nomes, chave) -> {chave, valor}
          Map.has_key?(rotulos, chave) -> {rotulos[chave], valor}
          true -> raise ArgumentError, "o formulário de #{caminho} não tem campo nem rótulo #{inspect(chave)}"
        end
      end)

    parametros =
      campos
      |> Enum.map(fn {nome, valor} -> {nome, Map.get(alteracoes, nome, valor)} end)
      |> URI.encode_query()
      |> Plug.Conn.Query.decode()

    navegar(quem, :post, acao, parametros)
  end

  defp ler_formulario(quem, caminho) do
    {:ok, conn} = visitar(quem, :get, caminho)

    case Regex.run(~r{<form\b.*?</form>}s, to_string(conn.resp_body)) do
      [formulario] ->
        [_, acao] = Regex.run(~r{<form\b[^>]*\saction="([^"]*)"}, formulario)

        campos =
          Regex.scan(~r{<input\b[^>]*>}, formulario)
          |> Enum.map(fn [tag] -> {atributo(tag, "name"), atributo(tag, "value") || ""} end)
          |> Enum.reject(fn {nome, _} -> nome in [nil, "_csrf_token"] end)

        # O rótulo de cada campo, como o <.input> dos CoreComponents o escreve:
        # <span class="label ...">Rótulo</span> logo antes do <input>.
        rotulos =
          Regex.scan(~r{<span[^>]*class="label[^"]*"[^>]*>\s*(.*?)\s*</span>\s*<input\b([^>]*)>}s, formulario)
          |> Map.new(fn [_, rotulo, atributos] -> {desescapar(rotulo), atributo(" " <> atributos, "name")} end)

        {desescapar(acao), campos, rotulos}

      nil ->
        mostra = texto(to_string(conn.resp_body))

        dica =
          if mostra =~ "Your cart is empty",
            do: " O carrinho está vazio: os itens não entraram. Se \"Add to cart\" deu erro de ON CONFLICT, falta o índice único da migration de cart_items (o primeiro exercício).",
            else: ""

        raise "a página #{caminho} não tem formulário (status #{conn.status}). O que ela mostra: " <>
                String.replace(mostra, "\n", " · ") <> "." <> dica
    end
  end

  defp atributo(tag, nome) do
    case Regex.run(~r{\s#{nome}="([^"]*)"}, tag) do
      [_, valor] -> desescapar(valor)
      nil -> nil
    end
  end

  defp desescapar(texto) do
    String.replace(texto, ["&amp;", "<", ">", """, "'"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
    end)
  end

  @doc """
  Apaga uma tabela e esquece a sua migration, para migrar de novo uma versão
  editada. No projeto, o caminho é `mix ecto.rollback`.
  """
  def desfazer_tabela(tabela, versao) do
    sql("DROP TABLE IF EXISTS #{tabela} CASCADE")
    sql("DELETE FROM schema_migrations WHERE version = $1", [versao])

    Kino.Markdown.new("A tabela `#{tabela}` foi apagada e a migration `#{versao}` deixou de constar como aplicada. Avalie a célula da migration e a do `mix ecto.migrate`.")
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      onde_carrinho: %{
        texto:
          "o carrinho precisa guardar os produtos que cada usuário escolheu, a quantidade e o preço no momento em que o item entrou. Onde fica esse código?",
        opcoes: [
          a: "No contexto `Catalog`, porque o carrinho guarda produtos do catálogo.",
          b: "Num contexto novo, `ShoppingCart`, porque as regras do carrinho não são as regras de exibir produtos.",
          c: "No `ProductController`, que já tem o produto em mãos na página dele.",
          d: "Num contexto `Products`, o nome que o gerador usaria se ninguém escolhesse outro."
        ],
        correta: :b,
        explicacao:
          "Exibir produtos e manter o carrinho de alguém são responsabilidades diferentes: o catálogo não precisa conhecer as regras do carrinho, e o carrinho não precisa conhecer as do catálogo. O guia cria o contexto `ShoppingCart` com dois recursos: `Cart`, o carrinho de cada usuário, e `CartItem`, cada produto dentro dele. Os dois se relacionam com o catálogo, mas estar relacionado não é motivo para morar no mesmo contexto."
      },
      autenticacao_autorizacao: %{
        texto:
          "depois do login, a aplicação sabe que quem faz a requisição é `bia@example.com`. Isso basta para garantir que a Bia só veja o próprio carrinho?",
        opcoes: [
          a: "Basta: o login já impede o acesso aos dados dos outros.",
          b: "Não: o login diz quem é a pessoa (autenticação); falta dizer o que ela pode acessar (autorização), e cada consulta precisa filtrar pelo dono.",
          c: "Basta, desde que a rota passe pelo plug `require_authenticated_user`.",
          d: "Não: é preciso pedir a senha de novo a cada requisição."
        ],
        correta: :b,
        explicacao:
          "`require_authenticated_user` só garante que há alguém logado. Se o contexto buscasse um carrinho só pelo `id`, a Bia poderia ler o carrinho de outra pessoa trocando o número na URL. Autorização é cada consulta filtrar pelo usuário. Os escopos do Phoenix 1.8 existem para isso: o contexto recebe o `%Scope{}` de quem chama e filtra por ele."
      },
      escopo_padrao: %{
        texto:
          "o `phx.gen.auth` registrou o escopo `user` como padrão em `config/config.exs`. Depois dele, o guia roda `mix phx.gen.context ShoppingCart Cart carts`, sem nenhum campo. Que colunas a tabela `carts` vai ter?",
        opcoes: [
          a: "Só `id`, `inserted_at` e `updated_at`, porque o comando não pediu campos.",
          b: "`id`, `user_id` (chave estrangeira para `users`) e os timestamps.",
          c: "`id`, uma coluna de texto com o e-mail do dono, e os timestamps.",
          d: "Nenhuma: o gerador recusa um schema sem campos."
        ],
        correta: :b,
        explicacao:
          "Com um escopo padrão configurado, os geradores acrescentam o campo do escopo (`schema_key: :user_id`, do tipo `schema_type: :id`, apontando para `schema_table: :users`) e escrevem todas as funções do contexto filtrando por ele. Para gerar sem escopo, passa-se `--no-scope`; para usar outro escopo, `--scope NOME`."
      },
      no_scope: %{
        texto: "o `CartItem` foi gerado com `--no-scope`, sem `user_id`. Como saber de quem é um item?",
        opcoes: [
          a: "Não dá: qualquer usuário pode alterar qualquer item.",
          b: "Pelo carrinho: o item pertence a um `Cart`, o `Cart` tem `user_id`, e as funções do contexto conferem o dono do carrinho antes de mexer nos itens.",
          c: "Pelo produto: cada produto tem um dono.",
          d: "Pela sessão: o Phoenix guarda os itens de cada usuário no cookie."
        ],
        correta: :b,
        explicacao:
          "O escopo vale para o recurso principal, o carrinho. Os itens chegam até o usuário por `cart_id`. Repetir `user_id` em cada item duplicaria a informação e abriria espaço para um item apontar para o carrinho de um usuário e para o `user_id` de outro. As funções que mexem em itens recebem o carrinho e conferem que ele é do usuário do escopo."
      },
      join_ou_api: %{
        texto: "o `ShoppingCart` precisa do título e do preço dos produtos, que pertencem ao `Catalog`. Que caminho o guia escolhe?",
        opcoes: [
          a: "Copiar título e preço para a tabela `cart_items`, para não depender do catálogo.",
          b: "Chamar `Catalog.get_product!/1` para cada item e juntar os dados em Elixir.",
          c: "Um `belongs_to :product, Hello.Catalog.Product` no `CartItem`, e joins no banco para trazer os produtos junto com o carrinho.",
          d: "Proibir qualquer referência entre contextos."
        ],
        correta: :c,
        explicacao:
          "O guia discute duas opções: expor funções no `Catalog` e juntar os dados à mão, ou usar joins do banco. Como o carrinho depende de verdade dos produtos (um item sem produto não existe), ele aceita o acoplamento de dados: o `CartItem` aponta para `Catalog.Product`, e `get_cart/1` traz tudo com joins. O acoplamento fica no schema. As regras continuam separadas: o `ShoppingCart` não sabe como o catálogo funciona."
      },
      price_cast: %{
        texto:
          "o changeset gerado do `CartItem` faz `cast` de `price_when_carted` e de `quantity`. O que pode dar errado se `price_when_carted` continuar no `cast`?",
        opcoes: [
          a: "Nada: o formulário do carrinho não tem campo de preço.",
          b: "Quem envia a requisição pode mandar `cart[items][0][price_when_carted]=0.01` e gravar o preço que quiser, mesmo sem campo no formulário.",
          c: "O preço seria convertido para inteiro.",
          d: "O Ecto recusaria o changeset, porque o preço já existe."
        ],
        correta: :b,
        explicacao:
          "O formulário é só o que o navegador costuma mandar; qualquer pessoa pode montar uma requisição com outros campos. O `cast/3` aceita todo campo da lista que vier nos parâmetros. Por isso o guia tira `price_when_carted` do `cast` e do `validate_required`: o preço é posto pelo código, a partir do produto (`%CartItem{price_when_carted: product.price}`), nunca pela entrada do usuário."
      },
      upsert: %{
        texto:
          "o carrinho da Bia já tem 1 unidade do produto 1. Ela clica de novo em \"Add to cart\" no mesmo produto. O que `add_item_to_cart/3` faz?",
        opcoes: [
          a: "Insere uma segunda linha em `cart_items`, com quantidade 1.",
          b: "Devolve `{:error, changeset}`, porque o produto já está no carrinho.",
          c: "Tenta o insert; o índice único de `[cart_id, product_id]` acusa o conflito, e o banco soma 1 à quantidade da linha que já existe.",
          d: "Apaga o item e insere de novo."
        ],
        correta: :c,
        explicacao:
          "É um upsert. `on_conflict: [inc: [quantity: 1]]` diz o que fazer quando o insert esbarra numa restrição, e `conflict_target: [:cart_id, :product_id]` diz qual restrição: o índice único da migration. Tudo vira um único `INSERT ... ON CONFLICT ... DO UPDATE`, atômico, sem ler antes de escrever. Sem o índice único, o PostgreSQL recusa o comando."
      },
      true_match: %{
        texto:
          "`update_cart/3` começa com `true = cart.user_id == scope.user.id`. O que acontece se algum código chamar a função com o escopo do Caio e o carrinho da Bia?",
        opcoes: [
          a: "Devolve `{:error, :unauthorized}`.",
          b: "Atualiza o carrinho da Bia normalmente.",
          c: "Levanta `MatchError`: `false` não casa com `true`, e o processo que chamou falha.",
          d: "Devolve `nil`."
        ],
        correta: :c,
        explicacao:
          "É uma asserção, não uma checagem com resposta. O código da aplicação nunca deveria chamar a função com o carrinho de outro dono; se chamar, é um bug, e é melhor falhar alto do que seguir em frente. Numa requisição, a exceção vira erro 500. Para o caso esperado, o de alguém pedir um recurso que não é seu, o contexto usa consultas filtradas pelo escopo, como `Repo.get_by!(Cart, id: id, user_id: scope.user.id)`, que levanta `Ecto.NoResultsError`, e essa exceção vira 404."
      },
      preco_atual: %{
        texto:
          "a Bia pôs 3 unidades de \"Metaprogramming Elixir\" no carrinho a $15. Depois a loja muda o preço para $20. O que a página do carrinho mostra no total?",
        opcoes: [
          a: "$45.00: o carrinho guardou o preço em `price_when_carted`.",
          b: "$60.00: `total_item_price/1` multiplica o preço atual do produto (`item.product.price`) pela quantidade.",
          c: "Um erro, porque os preços não batem.",
          d: "$15.00, o preço de uma unidade."
        ],
        correta: :b,
        explicacao:
          "O guia grava `price_when_carted`, mas as funções de total usam `item.product.price`, o preço de agora, que vem do join com `products`. O preço guardado serve para perceber a mudança, como o começo do guia anuncia; o exercício abaixo usa esse dado para avisar quem está comprando."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      migracao_itens: ~S'''
      defmodule Hello.Repo.Migrations.CreateCartItems do
        use Ecto.Migration

        def change do
          create table(:cart_items) do
            add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
            add :quantity, :integer
            add :cart_id, references(:carts, on_delete: :delete_all)
            add :product_id, references(:products, on_delete: :delete_all)

            timestamps(type: :utc_datetime)
          end

          create index(:cart_items, [:product_id])
          create unique_index(:cart_items, [:cart_id, :product_id])
        end
      end

      Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)

      # Se a versão gerada já tinha rodado, desfaça antes de migrar de novo:
      # Trilha.desfazer_tabela("cart_items", 20250205213410)
      ''',
      item_changeset: ~S'''
      defmodule Hello.ShoppingCart.CartItem do
        use Ecto.Schema
        import Ecto.Changeset

        schema "cart_items" do
          field :price_when_carted, :decimal
          field :quantity, :integer

          belongs_to :cart, Hello.ShoppingCart.Cart
          belongs_to :product, Hello.Catalog.Product

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(cart_item, attrs) do
          cart_item
          |> cast(attrs, [:quantity])
          |> validate_required([:quantity])
          |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
        end
      end
      ''',
      botao: ~S'''
      # Em show/1, dentro do slot :actions do <.header>, depois do botão "Edit product":
      <.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
        Add to cart
      </.button>
      ''',
      update_cart: ~S'''
      def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
        true = cart.user_id == scope.user.id

        changeset =
          cart
          |> Cart.changeset(attrs, scope)
          |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)

        Repo.transact(fn ->
          with {:ok, cart} <- Repo.update(changeset),
               {_count, _cart_items} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)) do
            {:ok, cart}
          end
        end)
        |> case do
          {:ok, cart} ->
            broadcast_cart(scope, {:updated, cart})
            {:ok, cart}

          {:error, reason} ->
            {:error, reason}
        end
      end
      ''',
      preco_mudou: ~S'''
      defmodule HelloWeb.CartHTML do
        use HelloWeb, :html

        alias Hello.ShoppingCart

        def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"

        def price_changed?(item), do: not Decimal.equal?(item.price_when_carted, item.product.price)

        # cart_html/show.html.heex
        def show(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>
              My Cart
              <:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
            </.header>

            <div :if={@cart.items !== []}>
              <.form :let={f} for={@changeset} action={~p"/cart"}>
                <.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
                  <.input field={item_form[:quantity]} type="number" label={item.product.title} />
                  {currency_to_str(ShoppingCart.total_item_price(item))}
                  <span :if={price_changed?(item)}>(was {currency_to_str(item.price_when_carted)})</span>
                </.inputs_for>
                <.button>Update cart</.button>
              </.form>
              <b>Total</b>: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
            </div>

            <.button navigate={~p"/products"}>Back to products</.button>
          </Layouts.app>
          """
        end
      end
      ''',
      brecho: ~S'''
      # priv/repo/migrations/20250322000000_create_desejos.exs
      defmodule Hello.Repo.Migrations.CreateDesejos do
        use Ecto.Migration

        def change do
          create table(:desejos) do
            add :user_id, references(:users, on_delete: :delete_all), null: false
            add :produto_id, references(:produtos, on_delete: :delete_all), null: false

            timestamps(type: :utc_datetime)
          end

          create unique_index(:desejos, [:user_id, :produto_id])
        end
      end

      Trilha.migracao(20250322000000, Hello.Repo.Migrations.CreateDesejos)

      # lib/hello/desejos/desejo.ex e lib/hello/desejos.ex
      defmodule Hello.Desejos.Desejo do
        use Ecto.Schema

        schema "desejos" do
          belongs_to :user, Hello.Accounts.User
          belongs_to :produto, Hello.Vitrine.Produto

          timestamps(type: :utc_datetime)
        end
      end

      defmodule Hello.Desejos do
        import Ecto.Query, warn: false
        alias Hello.Repo

        alias Hello.Accounts.Scope
        alias Hello.Desejos.Desejo

        def desejar(%Scope{} = scope, produto_id) do
          Repo.insert(%Desejo{user_id: scope.user.id, produto_id: String.to_integer("#{produto_id}")},
            on_conflict: :nothing,
            conflict_target: [:user_id, :produto_id]
          )
        end

        def listar(%Scope{} = scope) do
          Repo.all(
            from d in Desejo,
              where: d.user_id == ^scope.user.id,
              order_by: d.inserted_at,
              preload: :produto
          )
        end

        def remover(%Scope{} = scope, produto_id) do
          {quantos, _} =
            Repo.delete_all(from d in Desejo, where: d.user_id == ^scope.user.id and d.produto_id == ^produto_id)

          {:ok, quantos}
        end
      end

      # No router, num scope com pipe_through [:browser, :require_authenticated_user]:
      #   get "/desejos", DesejoController, :index
      #   post "/desejos", DesejoController, :create

      # lib/hello_web/controllers/desejo_controller.ex e desejo_html.ex
      defmodule HelloWeb.DesejoController do
        use HelloWeb, :controller

        alias Hello.Desejos

        def index(conn, _params) do
          render(conn, :index, desejos: Desejos.listar(conn.assigns.current_scope))
        end

        def create(conn, %{"produto_id" => produto_id}) do
          {:ok, _} = Desejos.desejar(conn.assigns.current_scope, produto_id)

          conn
          |> put_flash(:info, "Guardado na sua lista de desejos.")
          |> redirect(to: ~p"/desejos")
        end
      end

      defmodule HelloWeb.DesejoHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>
              Minha lista de desejos
              <:subtitle :if={@desejos == []}>Nenhuma peça guardada</:subtitle>
            </.header>
            <ul>
              <li :for={desejo <- @desejos}>{desejo.produto.nome} · {desejo.produto.preco_centavos} centavos</li>
            </ul>
          </Layouts.app>
          """
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      migracao_itens: [
        {"A tabela `cart_items` existe, com `price_when_carted`, `quantity`, `cart_id` e `product_id`.",
         fn -> colunas("cart_items", ~w(price_when_carted quantity cart_id product_id)) end},
        {"`price_when_carted` é `numeric(15,6)` e não aceita `NULL`.",
         fn ->
           case sql(
                  "SELECT numeric_precision, numeric_scale, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'cart_items' AND column_name = 'price_when_carted'"
                ).rows do
             [[15, 6, "NO"]] -> :ok
             [[nil, nil, _]] -> {:erro, "A coluna é um `numeric` sem precisão nem escala. Use `precision: 15, scale: 6, null: false`. " <> refazer_itens()}
             [[p, s, n]] -> {:erro, "A coluna está com precisão `#{p}`, escala `#{s}` e nullable `#{n}`. " <> refazer_itens()}
           end
         end},
        {"Apagar um carrinho ou um produto apaga os itens dele (`on_delete: :delete_all`).",
         fn ->
           regras =
             sql("""
             SELECT a.attname, c.confdeltype::text FROM pg_constraint c
             JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
             WHERE c.conrelid = 'cart_items'::regclass AND c.contype = 'f'
             """).rows
             |> Map.new(fn [coluna, regra] -> {coluna, regra} end)

           faltando = for coluna <- ["cart_id", "product_id"], regras[coluna] != "c", do: "`#{coluna}`"

           if faltando == [],
             do: :ok,
             else: {:erro, "As chaves #{Enum.join(faltando, " e ")} não apagam em cascata. Use `on_delete: :delete_all` no `references`. " <> refazer_itens()}
         end},
        {"O índice único de `[:cart_id, :product_id]` existe.",
         fn ->
           if Enum.any?(indices("cart_items"), &(&1 =~ "UNIQUE" and &1 =~ "(cart_id, product_id)")),
             do: :ok,
             else: {:erro, "Falta `create unique_index(:cart_items, [:cart_id, :product_id])`. " <> refazer_itens()}
         end},
        {"O índice só de `cart_id` saiu.",
         fn ->
           if Enum.any?(indices("cart_items"), &(&1 =~ "cart_items_cart_id_index")),
             do: {:erro, "O índice `cart_items_cart_id_index` continua lá. O índice único começa por `cart_id` e já serve às buscas por essa coluna. " <> refazer_itens()},
             else: :ok
         end}
      ],
      item_changeset: [
        {"`price_when_carted` vindo dos parâmetros é ignorado.",
         fn ->
           cs = changeset_item(%{"quantity" => "2", "price_when_carted" => "0.01"})

           if Map.has_key?(cs.changes, :price_when_carted),
             do: {:erro, "O changeset aceitou `price_when_carted: 0.01`. Tire o campo da lista do `cast/3`."},
             else: :ok
         end},
        {"Um item só com `quantity` é válido.",
         fn ->
           cs = changeset_item(%{"quantity" => "2"})

           if cs.valid?,
             do: :ok,
             else: {:erro, "O changeset ficou inválido: `#{inspect(cs.errors)}`. Tire `price_when_carted` também do `validate_required/3`."}
         end},
        {"`quantity` igual a 0 e a 99 é aceito.",
         fn ->
           invalidos = for q <- ["0", "99"], not changeset_item(%{"quantity" => q}).valid?, do: q

           if invalidos == [],
             do: :ok,
             else: {:erro, "Recusou `quantity` #{Enum.join(invalidos, " e ")}. O limite de baixo inclui o 0 (`greater_than_or_equal_to: 0`)."}
         end},
        {"`quantity` igual a 100 e a -1 é recusado por `validate_number/3`.",
         fn ->
           aceitos =
             for q <- ["100", "-1"],
                 match?(nil, changeset_item(%{"quantity" => q}).errors[:quantity]),
                 do: q

           if aceitos == [],
             do: :ok,
             else: {:erro, "Aceitou `quantity` #{Enum.join(aceitos, " e ")}. Use `validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)`."}
         end}
      ],
      botao: [
        {"A página `GET /products/1` tem um link \"Add to cart\".",
         fn ->
           case visitar("conferencia-anonimo", :get, "/products/1") do
             {:ok, %{status: 200} = conn} ->
               if to_string(conn.resp_body) =~ "Add to cart",
                 do: :ok,
                 else: {:erro, "A página não tem o texto \"Add to cart\". Acrescente o botão em `show/1` e avalie a célula do `ProductHTML` desta seção."}

             {:ok, conn} ->
               {:erro, "A página respondeu `#{conn.status}`. O produto 1 existe? A célula dos seeds roda depois do `Trilha.recomecar()`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"O link envia `POST /cart_items?product_id=1`.",
         fn ->
           {:ok, conn} = visitar("conferencia-anonimo", :get, "/products/1")

           case Regex.run(~r{<a\b[^>]*>\s*Add to cart}, to_string(conn.resp_body)) do
             [tag] ->
               cond do
                 not (tag =~ ~s(data-method="post")) -> {:erro, "O link não tem `method=\"post\"`; sem ele, o clique faz um `GET`."}
                 not (tag =~ ~s(data-to="/cart_items?product_id=1")) -> {:erro, "O link não aponta para `/cart_items?product_id=1`. Use `href={~p\"/cart_items?product_id=\#{@product.id}\"}`."}
                 true -> :ok
               end

             nil ->
               {:erro, "O texto \"Add to cart\" não está dentro de um link. Use `<.button href={...} method=\"post\">`."}
           end
         end}
      ],
      update_cart: [
        {"`PUT /cart` grava a nova quantidade de um item.",
         fn ->
           limpando(fn ->
             {quem, escopo} = entrar("conferencia-a")
             {:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
             item = item_no_carrinho(escopo, 1)
             {:ok, _} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "4"}]))

             case quantidade(item.id) do
               4 -> :ok
               q -> {:erro, "A quantidade continua `#{inspect(q)}`. O `update_cart/3` gerado só faz `cast` dos campos do próprio `Cart`; use `cast_assoc(:items, with: &CartItem.changeset/2)`."}
             end
           end)
         end},
        {"Quantidade 0 tira o item do carrinho.",
         fn ->
           limpando(fn ->
             {quem, escopo} = entrar("conferencia-a")
             {:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
             item = item_no_carrinho(escopo, 1)
             {:ok, _} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "0"}]))

             case quantidade(item.id) do
               nil -> :ok
               0 -> {:erro, "O item ficou na tabela com quantidade 0. Apague os itens zerados com `Repo.delete_all/1`, na mesma transação do `Repo.update/1`."}
               q -> {:erro, "A quantidade continua `#{q}`. O `update_cart/3` precisa de `cast_assoc(:items, ...)`."}
             end
           end)
         end},
        {"Quantidade 100 não muda nada e a página mostra a flash de erro.",
         fn ->
           limpando(fn ->
             {quem, escopo} = entrar("conferencia-a")
             {:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
             item = item_no_carrinho(escopo, 1)
             {:ok, conn} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "100"}]))
             flash = Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error)

             cond do
               quantidade(item.id) == 100 -> {:erro, "A quantidade 100 foi gravada. Falta a validação do exercício \"o changeset do item\"."}
               flash != "There was an error updating your cart" -> {:erro, "A página não mostrou a flash de erro. A action `update` trata o `{:error, _}`?"}
               true -> :ok
             end
           end)
         end}
      ],
      preco_mudou: [
        {"Sem mudança de preço, a página do carrinho não mostra \"(was\".",
         fn ->
           limpando(fn ->
             {quem, _escopo} = entrar("conferencia-a")
             produto = produto_de_conferencia("10")
             {:ok, _} = visitar(quem, :post, "/cart_items?product_id=#{produto.id}")
             {:ok, conn} = visitar(quem, :get, "/cart")

             if to_string(conn.resp_body) =~ "(was",
               do: {:erro, "O aviso aparece mesmo com o preço igual. Compare com `Decimal.equal?/2`: `10.000000` e `10` são o mesmo valor."},
               else: :ok
           end)
         end},
        {"Depois que o preço sobe de $10 para $12, a página mostra \"(was $10.00)\".",
         fn ->
           limpando(fn ->
             {quem, _escopo} = entrar("conferencia-a")
             produto = produto_de_conferencia("10")
             {:ok, _} = visitar(quem, :post, "/cart_items?product_id=#{produto.id}")
             sql("UPDATE products SET price = 12 WHERE id = $1", [produto.id])
             {:ok, conn} = visitar(quem, :get, "/cart")
             corpo = texto(to_string(conn.resp_body))

             cond do
               corpo =~ ~r/\(was\s*\$10\.00\)/ -> :ok
               corpo =~ "was" -> {:erro, "Apareceu um aviso, mas não `(was $10.00)`. O texto da página ficou assim:\n\n  `#{String.replace(corpo, "\n", " · ")}`"}
               true -> {:erro, "A página não avisa a mudança. Mostre `(was {currency_to_str(item.price_when_carted)})` quando o preço guardado for diferente de `item.product.price`."}
             end
           end)
         end}
      ],
      brecho: [
        {"A migration `20250322000000` está registrada e a tabela `desejos` tem `user_id`, `produto_id` e os timestamps.",
         fn ->
           if Enum.any?(migracoes(), fn {versao, _} -> versao == 20250322000000 end),
             do: colunas("desejos", ~w(user_id produto_id inserted_at updated_at)),
             else: {:erro, "Registre a migration com `Trilha.migracao(20250322000000, Hello.Repo.Migrations.CreateDesejos)` e rode o `mix ecto.migrate`."}
         end},
        {"Um índice único impede o mesmo produto duas vezes na lista de alguém.",
         fn ->
           if Enum.any?(indices("desejos"), &(&1 =~ "UNIQUE" and &1 =~ "(user_id, produto_id)")),
             do: :ok,
             else: {:erro, "Falta `create unique_index(:desejos, [:user_id, :produto_id])`."}
         end},
        {"`Hello.Desejos` define `desejar/2`, `listar/1` e `remover/2`.",
         fn -> exporta(@desejos, desejar: 2, listar: 1, remover: 2) end},
        {"Desejar duas vezes o mesmo produto guarda uma linha só, e `listar/1` traz o produto carregado.",
         fn ->
           limpando(fn ->
             {_quem, escopo} = entrar("conferencia-a")
             produto = peca_de_conferencia("Conferência Casaco")
             {:ok, _} = apply(@desejos, :desejar, [escopo, produto.id])
             {:ok, _} = apply(@desejos, :desejar, [escopo, produto.id])

             case apply(@desejos, :listar, [escopo]) do
               [%{produto: %{nome: "Conferência Casaco"}}] -> :ok
               [%{produto: %Ecto.Association.NotLoaded{}}] -> {:erro, "O produto não veio carregado. Use `preload: :produto` na consulta."}
               lista when is_list(lista) -> {:erro, "`listar/1` devolveu #{length(lista)} desejo(s): `#{inspect(lista, limit: 5)}`."}
               outro -> {:erro, "`listar/1` devolveu `#{inspect(outro, limit: 5)}`, e não uma lista."}
             end
           end)
         end},
        {"A lista de um usuário não mostra os desejos de outro, e `remover/2` tira só do próprio.",
         fn ->
           limpando(fn ->
             {_a, escopo_a} = entrar("conferencia-a")
             {_b, escopo_b} = entrar("conferencia-b")
             produto = peca_de_conferencia("Conferência Saia")
             {:ok, _} = apply(@desejos, :desejar, [escopo_a, produto.id])
             {:ok, _} = apply(@desejos, :desejar, [escopo_b, produto.id])
             apply(@desejos, :remover, [escopo_b, produto.id])

             cond do
               length(apply(@desejos, :listar, [escopo_a])) != 1 -> {:erro, "A lista do usuário A deveria ter 1 desejo depois que B removeu o dele."}
               apply(@desejos, :listar, [escopo_b]) != [] -> {:erro, "O desejo de B continua na lista dele depois de `remover/2`."}
               true -> :ok
             end
           end)
         end},
        {"Sem login, `POST /desejos?produto_id=…` leva para a página de login.",
         fn ->
           limpando(fn ->
             produto = peca_de_conferencia("Conferência Boné")

             case visitar("conferencia-anonimo", :post, "/desejos?produto_id=#{produto.id}") do
               {:ok, %{request_path: "/users/log-in"}} -> :ok
               {:ok, conn} -> {:erro, "A última página foi `#{conn.request_path}`, com status `#{conn.status}`. As rotas precisam passar por `:require_authenticated_user`."}
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end},
        {"Com login, `POST /desejos?produto_id=…` redireciona para `GET /desejos`, que lista a peça só para quem a guardou.",
         fn ->
           limpando(fn ->
             {a, _} = entrar("conferencia-a")
             {b, _} = entrar("conferencia-b")
             produto = peca_de_conferencia("Conferência Lenço")

             with {:ok, conn_a} <- visitar(a, :post, "/desejos?produto_id=#{produto.id}"),
                  {:ok, conn_b} <- visitar(b, :get, "/desejos") do
               cond do
                 conn_a.request_path != "/desejos" -> {:erro, "Depois do `POST`, a última página foi `#{conn_a.request_path}`. Redirecione para `~p\"/desejos\"`."}
                 not (to_string(conn_a.resp_body) =~ "Conferência Lenço") -> {:erro, "A página `/desejos` não mostra o nome da peça guardada."}
                 to_string(conn_b.resp_body) =~ "Conferência Lenço" -> {:erro, "A lista de outro usuário mostrou a peça. Filtre pelo escopo."}
                 true -> :ok
               end
             else
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end}
      ]
    }
  end

  # --- ajudantes da conferência --------------------------------------------------

  defp exporta(modulo, funcoes) do
    faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)

    if faltando == [],
      do: :ok,
      else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
  end

  defp colunas(tabela, esperadas) do
    existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])

    cond do
      existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode a célula do `mix ecto.migrate`."}
      (faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
      true -> :ok
    end
  end

  defp coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp indices(tabela), do: coluna("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1", [tabela])

  defp refazer_itens do
    "Se a migration já tinha rodado antes da mudança, avalie `Trilha.desfazer_tabela(\"cart_items\", 20250205213410)` e depois a célula da migration e a do `mix ecto.migrate`."
  end

  defp existe?(tabela), do: coluna("SELECT to_regclass($1)::text", ["public." <> tabela]) != [nil]

  defp changeset_item(params), do: apply(@item, :changeset, [struct(@item), params])

  # Um usuário só da conferência, logado pela rota de login, como a Bia e o Caio.
  defp entrar(quem) do
    email = quem <> "@example.com"
    {:ok, _} = visitar(quem, :post, "/users/log-in", %{"user" => %{"email" => email}})
    usuario = apply(@contas, :get_user_by_email, [email])
    {quem, apply(@escopo, :for_user, [usuario])}
  end

  defp item_no_carrinho(escopo, produto_id) do
    carrinho = apply(@carrinho, :get_cart, [escopo])
    Enum.find(carrinho.items, &(&1.product_id == produto_id)) || raise "o produto #{produto_id} não entrou no carrinho"
  end

  defp quantidade(item_id) do
    case coluna("SELECT quantity FROM cart_items WHERE id = $1", [item_id]) do
      [q] -> q
      [] -> nil
    end
  end

  defp parametros_do_carrinho(itens) do
    itens =
      itens
      |> Enum.with_index()
      |> Map.new(fn {{id, quantidade}, i} -> {to_string(i), %{"id" => to_string(id), "quantity" => quantidade}} end)

    %{"cart" => %{"items" => itens}}
  end

  defp produto_de_conferencia(preco) do
    {:ok, produto} =
      apply(@catalogo, :create_product, [
        %{"title" => "Conferência Livro", "description" => "Produto da conferência", "price" => preco, "views" => "0"}
      ])

    produto
  end

  defp peca_de_conferencia(nome) do
    {:ok, peca} = apply(@vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: 1000}])
    peca
  end

  defp limpando(teste) do
    teste.()
  after
    limpar_conferencia()
  end

  # Apaga tudo o que as conferências criaram, na ordem que as chaves estrangeiras exigem.
  defp limpar_conferencia do
    usuarios = "SELECT id FROM users WHERE email LIKE 'conferencia-%'"

    comandos = [
      {"cart_items", "DELETE FROM cart_items WHERE cart_id IN (SELECT id FROM carts WHERE user_id IN (#{usuarios}))"},
      {"cart_items", "DELETE FROM cart_items WHERE product_id IN (SELECT id FROM products WHERE title LIKE 'Conferência%')"},
      {"carts", "DELETE FROM carts WHERE user_id IN (#{usuarios})"},
      {"desejos", "DELETE FROM desejos WHERE user_id IN (#{usuarios})"},
      {"desejos", "DELETE FROM desejos WHERE produto_id IN (SELECT id FROM produtos WHERE nome LIKE 'Conferência%')"},
      {"product_categories", "DELETE FROM product_categories WHERE product_id IN (SELECT id FROM products WHERE title LIKE 'Conferência%')"},
      {"products", "DELETE FROM products WHERE title LIKE 'Conferência%'"},
      {"produtos", "DELETE FROM produtos WHERE nome LIKE 'Conferência%'"},
      {"users", "DELETE FROM users WHERE email LIKE 'conferencia-%'"}
    ]

    for {tabela, comando} <- comandos, existe?(tabela) do
      try do
        sql(comando)
      rescue
        _ -> :ignorado
      end
    end

    :ok
  end

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

    cond do
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."

      true ->
        "`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
    end
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :invalid_column_reference}}) do
    "O PostgreSQL recusou o `ON CONFLICT`: falta o índice único de `[:cart_id, :product_id]` em `cart_items`. Volte ao exercício da migration dos itens."
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
    "O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
  end

  defp explicar(%Phoenix.ActionClauseError{}) do
    "Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook acompanha o guia Cross-context Boundaries, o quarto dos guias de modelagem de dados da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. Os notebooks 08 e 09 montaram o contexto Catalog, com produtos e categorias. Agora a loja ganha um carrinho de compras, que mora num contexto novo, ShoppingCart, e que depende de dois outros: do Catalog, porque cada item é um produto, e de Accounts, porque cada carrinho é de um usuário.

O guia começa gerando a autenticação com mix phx.gen.auth. Um notebook não roda geradores, e a autenticação completa (senhas, links de login por e-mail, tokens) é assunto de um módulo próprio da trilha. Por isso este notebook traz uma autenticação reduzida, com os mesmos nomes de módulos e de funções que o gerador cria. A seção "Autenticação" explica exatamente o que ficou de fora.

As convenções são as de sempre: uma célula equivale a um arquivo, com o caminho em negrito acima dela; as perguntas "Antes de continuar" vêm antes da explicação; os erros fazem parte do conteúdo. Três ajudantes do módulo Trilha fazem o papel do navegador:

  • Trilha.navegar(quem, método, caminho, parâmetros) faz uma requisição como um navegador faria. Cada visitante ("bia", "caio") tem os seus próprios cookies, então quem fez login continua logado nas requisições seguintes. Os redirects são seguidos, e o resultado mostra cada passo, a flash, quem está logado e o texto da página final.
  • Trilha.formulario(quem, caminho) mostra os campos que o formulário de uma página enviaria.
  • Trilha.enviar(quem, caminho, alterações) preenche esse formulário e o envia, como um clique no botão.

Você também pode abrir as páginas no navegador, em http://localhost:4010. Lá, faça login em http://localhost:4010/users/log-in com qualquer e-mail.

Este notebook usa o PostgreSQL, como o 07. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex: o router do notebook 09, com as rotas dos produtos.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/products", ProductController
  end
end

lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  plug Plug.Static,
    at: "/assets",
    from: {:phoenix_html, "priv/static"},
    only: ~w(phoenix_html.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

O catálogo dos notebooks 08 e 09

O contexto Catalog no estado em que o guia anterior, In-context Relationships, o deixou: produtos com categorias, o contador de visitas e as páginas geradas pelo mix phx.gen.html. Para encurtar, as funções geradas aparecem sem os @doc, que você leu no notebook 08.

priv/repo/migrations/: as três migrations do catálogo.

defmodule Hello.Repo.Migrations.CreateProducts do
  use Ecto.Migration

  def change do
    create table(:products) do
      add :title, :string
      add :description, :string
      add :price, :decimal, precision: 15, scale: 6, null: false
      add :views, :integer, default: 0, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

defmodule Hello.Repo.Migrations.CreateCategories do
  use Ecto.Migration

  def change do
    create table(:categories) do
      add :title, :string

      timestamps(type: :utc_datetime)
    end

    create unique_index(:categories, [:title])
  end
end

defmodule Hello.Repo.Migrations.CreateProductCategories do
  use Ecto.Migration

  def change do
    create table(:product_categories, primary_key: false) do
      add :product_id, references(:products, on_delete: :delete_all)
      add :category_id, references(:categories, on_delete: :delete_all)
    end

    create index(:product_categories, [:product_id])
    create unique_index(:product_categories, [:category_id, :product_id])
  end
end

Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
Trilha.migracao(20250203192325, Hello.Repo.Migrations.CreateCategories)
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)

lib/hello/catalog/category.ex e lib/hello/catalog/product.ex

defmodule Hello.Catalog.Category do
  use Ecto.Schema
  import Ecto.Changeset

  schema "categories" do
    field :title, :string

    timestamps(type: :utc_datetime)
  end

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

defmodule Hello.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  alias Hello.Catalog.Category

  schema "products" do
    field :title, :string
    field :description, :string
    field :price, :decimal
    field :views, :integer

    many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :description, :price, :views])
    |> validate_required([:title, :description, :price, :views])
  end
end

lib/hello/catalog.ex

defmodule Hello.Catalog do
  @moduledoc """
  The Catalog context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Catalog.Product
  alias Hello.Catalog.Category

  def list_products do
    Repo.all(Product)
  end

  def get_product!(id) do
    Product
    |> Repo.get!(id)
    |> Repo.preload(:categories)
  end

  def create_product(attrs) do
    %Product{}
    |> change_product(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
    |> change_product(attrs)
    |> Repo.update()
  end

  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
    categories = list_categories_by_id(attrs["category_ids"])

    product
    |> Repo.preload(:categories)
    |> Product.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:categories, categories)
  end

  def list_categories_by_id(nil), do: []

  def list_categories_by_id(category_ids) do
    Repo.all(from c in Category, where: c.id in ^category_ids)
  end

  def inc_page_views(%Product{} = product) do
    {1, [%Product{views: views}]} =
      from(p in Product, where: p.id == ^product.id, select: [:views])
      |> Repo.update_all(inc: [views: 1])

    put_in(product.views, views)
  end

  def list_categories do
    Repo.all(Category)
  end

  def get_category!(id), do: Repo.get!(Category, id)

  def create_category(attrs) do
    %Category{}
    |> Category.changeset(attrs)
    |> Repo.insert()
  end

  def update_category(%Category{} = category, attrs) do
    category
    |> Category.changeset(attrs)
    |> Repo.update()
  end

  def delete_category(%Category{} = category) do
    Repo.delete(category)
  end

  def change_category(%Category{} = category, attrs \\ %{}) do
    Category.changeset(category, attrs)
  end
end

lib/hello_web/controllers/product_controller.ex

defmodule HelloWeb.ProductController do
  use HelloWeb, :controller

  alias Hello.Catalog
  alias Hello.Catalog.Product

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, :index, products: products)
  end

  def new(conn, _params) do
    changeset = Catalog.change_product(%Product{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created successfully.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    product =
      id
      |> Catalog.get_product!()
      |> Catalog.inc_page_views()

    render(conn, :show, product: product)
  end

  def edit(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    changeset = Catalog.change_product(product)
    render(conn, :edit, product: product, changeset: changeset)
  end

  def update(conn, %{"id" => id, "product" => product_params}) do
    product = Catalog.get_product!(id)

    case Catalog.update_product(product, product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product updated successfully.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, product: product, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    {:ok, _product} = Catalog.delete_product(product)

    conn
    |> put_flash(:info, "Product deleted successfully.")
    |> redirect(to: ~p"/products")
  end
end

lib/hello_web/controllers/product_html.ex e os templates de product_html/, um por função.

defmodule HelloWeb.ProductHTML do
  use HelloWeb, :html

  def category_opts(changeset) do
    existing_ids =
      changeset
      |> Ecto.Changeset.get_change(:categories, [])
      |> Enum.map(& &1.data.id)

    for cat <- Hello.Catalog.list_categories() do
      [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
    end
  end

  # product_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Listing Products
        <:actions>
          <.button href={~p"/products/new"}>
            <.icon name="hero-plus" /> New Product
          </.button>
        </:actions>
      </.header>

      <.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
        <:col :let={product} label="Title">{product.title}</:col>
        <:col :let={product} label="Description">{product.description}</:col>
        <:col :let={product} label="Price">{product.price}</:col>
        <:col :let={product} label="Views">{product.views}</:col>
        <:action :let={product}>
          <div class="sr-only">
            <.link navigate={~p"/products/#{product}"}>Show</.link>
          </div>
          <.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
        </:action>
        <:action :let={product}>
          <.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # product_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Product {@product.id}
        <:subtitle>This is a product record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/products"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit product
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@product.title}</:item>
        <:item title="Description">{@product.description}</:item>
        <:item title="Price">{@product.price}</:item>
        <:item title="Views">{@product.views}</:item>
        <:item title="Categories">
          <ul>
            <li :for={cat <- @product.categories}>{cat.title}</li>
          </ul>
        </:item>
      </.list>
    </Layouts.app>
    """
  end

  # product_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        New Product
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  # product_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Edit Product {@product.id}
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  @doc """
  Renders a product form.

  The form is defined in the template at
  product_html/product_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # product_html/product_form.html.heex
  def product_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <.input field={f[:description]} type="text" label="Description" />
      <.input field={f[:price]} type="number" label="Price" step="any" />
      <.input field={f[:views]} type="number" label="Views" />

      <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />

      <footer>
        <.button variant="primary">Save Product</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.

Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()

Crie as tabelas do catálogo (é o mix ecto.migrate):

Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

priv/repo/seeds.exs: as categorias do notebook 09 e dois produtos do guia. O segundo já entra com duas categorias.

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
  {:ok, _} = Hello.Catalog.create_category(%{title: title})
end

{:ok, _} =
  Hello.Catalog.create_product(%{
    "title" => "Metaprogramming Elixir",
    "description" => "Write Less Code, Get More Done (and Have Fun!)",
    "price" => "15",
    "views" => "0"
  })

{:ok, _} =
  Hello.Catalog.create_product(%{
    "title" => "Elixir Flashcards",
    "description" => "Flash card set for the Elixir programming language",
    "price" => "5",
    "views" => "0",
    "category_ids" => ["4", "5"]
  })

Trilha.consultar("SELECT id, title, price, views FROM products ORDER BY id")

Um contexto novo para o carrinho

Na documentação: Cross-context Boundaries

O catálogo já exibe produtos. O passo seguinte de uma loja é deixar alguém pôr produtos num carrinho. Para isso a aplicação precisa guardar informação nova: quais produtos cada usuário escolheu, quantos de cada um e o preço no momento em que o item entrou no carrinho. Guardar esse preço permite perceber, mais tarde, que ele mudou.

Trilha.pergunta(:onde_carrinho)

O guia lista quatro requisitos para o contexto ShoppingCart:

  1. adicionar produtos ao carrinho do usuário, a partir da página do produto;
  2. guardar o preço do produto no momento em que ele entrou no carrinho;
  3. guardar e atualizar a quantidade de cada item;
  4. calcular e mostrar a soma dos preços.

Daí saem dois recursos: Cart, o carrinho de cada usuário, e CartItem, cada produto dentro dele.

Autenticação

Na documentação: Adding authentication

Quase tudo no carrinho depende de saber quem é o usuário: cada um deve ver e mudar só o próprio carrinho. O guia gera um sistema de autenticação com o gerador do Phoenix:

mix phx.gen.auth Accounts User users

Os argumentos seguem a convenção dos outros geradores: o contexto (Accounts), o schema (User) e a tabela (users). Antes de criar os arquivos, o gerador pergunta:

An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Yn]

O guia responde n, porque a loja usa controllers. Num projeto com Phoenix 1.8.16, essa resposta cria estes arquivos:

lib/hello/accounts.ex                          o contexto Accounts
lib/hello/accounts/user.ex                     o schema User: e-mail, hash da senha, confirmação
lib/hello/accounts/user_token.ex               tokens de sessão e dos links enviados por e-mail
lib/hello/accounts/user_notifier.ex            os e-mails de login e de troca de endereço
lib/hello/accounts/scope.ex                    o escopo, assunto desta seção
lib/hello_web/user_auth.ex                     os plugs de autenticação
lib/hello_web/controllers/user_session_*       login (por link ou senha) e logout
lib/hello_web/controllers/user_registration_*  cadastro
lib/hello_web/controllers/user_settings_*      troca de e-mail e de senha
priv/repo/migrations/..._create_users_auth_tables.exs   as tabelas users e users_tokens
test/...                                       os testes de tudo isso

E altera estes:

  • mix.exs: acrescenta {:bcrypt_elixir, "~> 3.0"}, a biblioteca que calcula o hash das senhas;
  • config/config.exs: registra o escopo user (a seção "Escopos", abaixo, mostra o bloco);
  • lib/hello_web/router.ex: importa o HelloWeb.UserAuth, põe o plug :fetch_current_scope_for_user na pipeline :browser e acrescenta as rotas de autenticação;
  • root.html.heex: o menu do topo, com o e-mail de quem está logado e os links de login e logout;
  • config/test.exs e test/support/conn_case.ex: ajustes para os testes.

No fim, o gerador pede mix deps.get (para baixar o bcrypt_elixir) e mix ecto.migrate, e sugere criar uma conta em /users/register e ver o e-mail de confirmação em /dev/mailbox.

As rotas que ele acrescenta ao router ficam em três blocos, cada um com uma exigência diferente:

scope "/", HelloWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  get "/users/register", UserRegistrationController, :new
  post "/users/register", UserRegistrationController, :create
end

scope "/", HelloWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/users/settings", UserSettingsController, :edit
  put "/users/settings", UserSettingsController, :update
  get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end

scope "/", HelloWeb do
  pipe_through [:browser]

  get "/users/log-in", UserSessionController, :new
  get "/users/log-in/:token", UserSessionController, :confirm
  post "/users/log-in", UserSessionController, :create
  delete "/users/log-out", UserSessionController, :delete
end

O cadastro passa por :redirect_if_user_is_authenticated: quem já está logado volta para a página inicial. As configurações passam por :require_authenticated_user: quem não está logado vai para a página de login. O guia mostra essas rotas como /user/register e /user/settings, no singular; o gerador do 1.8.16 usa /users/..., no plural.

Na trilha: uma autenticação reduzida

O phx.gen.auth completo depende do bcrypt_elixir, que tem código em C, e de e-mail: no 1.8, o login padrão é por um link enviado ao endereço do usuário. Nada disso cabe bem num notebook, e a autenticação completa é assunto de um módulo próprio da trilha, mais adiante. Aqui você usa uma versão reduzida, com os mesmos nomes de módulos e de funções:

No phx.gen.auth Nesta versão reduzida
Login por link enviado por e-mail, ou por e-mail e senha Login só com o e-mail; um e-mail novo cria a conta na hora
Tabela users com email (citext), hashed_password e confirmed_at Tabela users só com email
Tabela users_tokens; a sessão guarda um token, que pode ser revogado Sem tokens; a sessão guarda o user_id
Cadastro, confirmação, troca de e-mail e de senha, "lembrar de mim", modo sudo Só login e logout
Accounts, User, Scope, UserAuth com fetch_current_scope_for_user, require_authenticated_user, redirect_if_user_is_authenticated, log_in_user, log_out_user Os mesmos módulos e funções, com o mesmo papel

O que o guia usa está igual: o Scope, o assign current_scope e os plugs que protegem as rotas. Não leve esta versão para um projeto de verdade: nela, qualquer pessoa entra com qualquer e-mail, sem senha.

priv/repo/migrations/20250205200000_create_users_auth_tables.exs: só a tabela users, com um índice único no e-mail.

defmodule Hello.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])
  end
end

Trilha.migracao(20250205200000, Hello.Repo.Migrations.CreateUsersAuthTables)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/accounts/user.ex, lib/hello/accounts.ex e lib/hello/accounts/scope.ex. O Scope é o arquivo gerado, sem mudança nenhuma.

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string

    timestamps(type: :utc_datetime)
  end

  @doc "A user changeset for registering or changing the email."
  def email_changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces")
    |> validate_length(:email, max: 160)
    |> unique_constraint(:email)
  end
end

defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.User

  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: email)
  end

  def get_user!(id), do: Repo.get!(User, id)

  def get_user(id), do: Repo.get(User, id)

  def register_user(attrs) do
    %User{}
    |> User.email_changeset(attrs)
    |> Repo.insert()
  end
end

defmodule Hello.Accounts.Scope do
  @moduledoc """
  Defines the scope of the caller to be used throughout the app.

  The `Hello.Accounts.Scope` allows public interfaces to receive
  information about the caller, such as if the call is initiated from an
  end-user, and if so, which user. Additionally, such a scope can carry fields
  such as "super user" or other privileges for use in authorization checks,
  or to ensure specific code paths can only be accessed for a given scope.

  It is useful for logging as well as for scoping pubsub subscriptions and
  broadcasts when a caller subscribes to an interface or performs a particular
  action.

  Feel free to extend the fields on this struct to fit the needs of
  growing application requirements.
  """

  alias Hello.Accounts.User

  defstruct user: nil

  @doc """
  Creates a scope for the given user.

  Returns nil if no user is given.
  """
  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end

lib/hello_web/user_auth.ex: os plugs. require_authenticated_user/2, redirect_if_user_is_authenticated/2 e maybe_store_return_to/1 são iguais aos gerados. fetch_current_scope_for_user/2, log_in_user/2 e log_out_user/1 fazem o mesmo trabalho com o user_id na sessão, em vez de um token.

defmodule HelloWeb.UserAuth do
  use HelloWeb, :verified_routes

  import Plug.Conn
  import Phoenix.Controller

  alias Hello.Accounts
  alias Hello.Accounts.Scope

  @doc """
  Logs the user in.

  Redirects to the session's `:user_return_to` path
  or falls back to the `signed_in_path/1`.
  """
  def log_in_user(conn, user) do
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> renew_session()
    |> put_session(:user_id, user.id)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

  @doc "Logs the user out. It clears all session data for safety."
  def log_out_user(conn) do
    conn
    |> renew_session()
    |> redirect(to: ~p"/")
  end

  @doc "Authenticates the user by looking into the session."
  def fetch_current_scope_for_user(conn, _opts) do
    user = if user_id = get_session(conn, :user_id), do: Accounts.get_user(user_id)
    assign(conn, :current_scope, Scope.for_user(user))
  end

  defp renew_session(conn) do
    delete_csrf_token()

    conn
    |> configure_session(renew: true)
    |> clear_session()
  end

  @doc """
  Plug for routes that require the user to not be authenticated.
  """
  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns.current_scope do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

  defp signed_in_path(_conn), do: ~p"/"

  @doc """
  Plug for routes that require the user to be authenticated.
  """
  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  defp maybe_store_return_to(%{method: "GET"} = conn) do
    put_session(conn, :user_return_to, current_path(conn))
  end

  defp maybe_store_return_to(conn), do: conn
end

A célula avisa no route path for HelloWeb.Router matches "/users/log-in". É verdade, por enquanto: o router ainda é o do topo, sem as rotas de login. O ~p confere cada caminho quando o módulo compila, e o router novo, mais abaixo, precisa do UserAuth já compilado, porque o importa. No projeto, o compilador resolve essa dependência circular sozinho e o aviso não aparece. Aqui, se quiser vê-lo sumir, avalie esta célula de novo depois do router.

lib/hello_web/controllers/user_session_controller.ex: as actions de entrar e sair. O create busca o usuário pelo e-mail e, se ele não existe, o cadastra. Esta célula vem antes do router porque não usa ~p; assim o router já encontra o controller.

defmodule HelloWeb.UserSessionController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias HelloWeb.UserAuth

  def new(conn, _params) do
    render(conn, :new, form: Phoenix.Component.to_form(%{}, as: "user"))
  end

  def create(conn, %{"user" => %{"email" => email}}) do
    case Accounts.get_user_by_email(email) || Accounts.register_user(%{email: email}) do
      {:error, changeset} ->
        render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))

      {:ok, user} ->
        conn
        |> put_flash(:info, "Account created. Welcome!")
        |> UserAuth.log_in_user(user)

      user ->
        conn
        |> put_flash(:info, "Welcome back!")
        |> UserAuth.log_in_user(user)
    end
  end

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Logged out successfully.")
    |> UserAuth.log_out_user()
  end
end

lib/hello_web/router.ex: o router do topo com as mudanças do phx.gen.auth. O import HelloWeb.UserAuth traz os plugs de função para dentro do router; a pipeline :browser ganha :fetch_current_scope_for_user; as rotas de login e logout entram num bloco próprio. Daqui em diante, cada mudança no router é uma célula nova, com o router inteiro, porque ele depende de módulos definidos mais abaixo.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/products", ProductController
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

lib/hello_web/controllers/user_session_html.ex: a página de login, com um formulário que só pede o e-mail.

defmodule HelloWeb.UserSessionHTML do
  use HelloWeb, :html

  # user_session_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="mx-auto max-w-sm space-y-4">
        <.header>
          Log in
          <:subtitle>Trilha version: just the email, no password. New emails create an account.</:subtitle>
        </.header>

        <.form :let={f} for={@form} action={~p"/users/log-in"}>
          <.input field={f[:email]} type="email" label="Email" required />
          <.button variant="primary" class="btn btn-primary w-full">Log in</.button>
        </.form>
      </div>
    </Layouts.app>
    """
  end
end

lib/hello_web/components/layouts.ex: o root layout ganha o menu que o phx.gen.auth injeta no root.html.heex. A versão gerada também tem os links "Settings" e "Register", que a versão reduzida não tem.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        <ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
          <%= if @current_scope do %>
            <li>
              {@current_scope.user.email}
            </li>
            <li>
              <.link href={~p"/users/log-out"} method="delete">Log out</.link>
            </li>
          <% else %>
            <li>
              <.link href={~p"/users/log-in"}>Log in</.link>
            </li>
          <% end %>
        </ul>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

A Bia abre a loja. Ninguém fez login ainda:

Trilha.navegar("bia", :get, "/")

Agora ela entra com o e-mail. O formulário de login envia user[email], que chega ao controller como %{"user" => %{"email" => ...}}:

Trilha.navegar("bia", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})

O POST respondeu 302 para /, com a flash "Account created. Welcome!", e a página seguinte já mostra quem está logado. Entre as duas requisições, quem guardou o login foi o cookie da sessão: log_in_user/2 pôs o user_id nela, e o navegador (aqui, o Trilha.navegar) mandou o cookie de volta. No navegador de verdade, faça o mesmo em http://localhost:4010/users/log-in; o menu no topo passa a mostrar o seu e-mail.

Escopos

Trilha.pergunta(:autenticacao_autorizacao)

Autenticação diz quem é o usuário. Autorização diz o que ele pode acessar. O phx.gen.auth do 1.8 resolve a primeira e deixa uma ferramenta para a segunda: o escopo, o módulo Hello.Accounts.Scope da célula acima. É uma struct com um campo, user, criada por Scope.for_user/1 (que devolve nil quando não há usuário). O plug fetch_current_scope_for_user a coloca em conn.assigns.current_scope a cada requisição:

{:ok, conn} = Trilha.visitar("bia", :get, "/")
conn.assigns.current_scope

Por que uma struct só para guardar o usuário? Porque ela pode crescer: uma organização, um papel de administrador, um time. Os contextos recebem o escopo inteiro, e não o usuário, então acrescentar um campo não muda a assinatura de nenhuma função.

O gerador também registrou o escopo em config/config.exs. Este é o bloco que o phx.gen.auth do 1.8.16 escreve no projeto:

config :hello, :scopes,
  user: [
    default: true,
    module: Hello.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:user, :id],
    schema_key: :user_id,
    schema_type: :id,
    schema_table: :users,
    test_data_fixture: Hello.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user
  ]

Esse bloco não muda nada quando a aplicação roda: quem o lê são os geradores. Ele diz que o escopo user é o padrão (default: true), que fica no assign current_scope, que o dono de um registro se encontra em scope.user.id (access_path) e que, nas tabelas, ele vira a coluna user_id, do tipo :id, apontando para users. As duas últimas chaves dizem aos testes gerados como criar um usuário e fazer login. A seção seguinte mostra o efeito.

Recursos com escopo

Na documentação: Generating scoped resources

Com a autenticação no lugar, o guia gera o contexto do carrinho:

mix phx.gen.context ShoppingCart Cart carts
Trilha.pergunta(:escopo_padrao)

A saída do gerador:

* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20250205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

priv/repo/migrations/20250205203128_create_carts.exs: a migration gerada, sem mudanças. O user_id veio do escopo, com on_delete: :delete_all: apagar um usuário apaga o carrinho dele.

defmodule Hello.Repo.Migrations.CreateCarts do
  use Ecto.Migration

  def change do
    create table(:carts) do
      add :user_id, references(:users, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create index(:carts, [:user_id])
  end
end

Trilha.migracao(20250205203128, Hello.Repo.Migrations.CreateCarts)

O schema gerado, lib/hello/shopping_cart/cart.ex, ficou assim (a linha em branco depois de schema "carts" do é do gerador, que não tinha campo nenhum para pôr ali):

defmodule Hello.ShoppingCart.Cart do
  use Ecto.Schema
  import Ecto.Changeset

  schema "carts" do

    field :user_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(cart, attrs, user_scope) do
    cart
    |> cast(attrs, [])
    |> validate_required([])
    |> put_change(:user_id, user_scope.user.id)
  end
end

Repare no changeset/3. Ele recebe o escopo e põe o user_id com put_change/3. O dono do carrinho nunca vem dos parâmetros, que qualquer um pode forjar: vem de quem está logado.

O contexto gerado, lib/hello/shopping_cart.ex, segue o mesmo padrão em todas as funções. O primeiro argumento é sempre o escopo, e toda consulta filtra pelo dono:

def list_carts(%Scope{} = scope) do
  Repo.all_by(Cart, user_id: scope.user.id)
end

def get_cart!(%Scope{} = scope, id) do
  Repo.get_by!(Cart, id: id, user_id: scope.user.id)
end

def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
  true = cart.user_id == scope.user.id
  ...
end

get_cart!/2 só encontra o carrinho se ele for do usuário do escopo. Com o id do carrinho de outra pessoa, a consulta não acha nada e levanta Ecto.NoResultsError. As funções que recebem um carrinho já carregado, como update_cart/3, começam conferindo o dono com true = .... A seção "Cada um com o seu carrinho", mais adiante, mostra as duas coisas acontecendo.

O contexto gerado ainda tem subscribe_carts/1 e uma função privada, broadcast_cart/2, que publicam e assinam avisos de mudança pelo Phoenix.PubSub, num tópico por usuário ("user:#{key}:carts"). create_cart, update_cart e delete_cart chamam broadcast_cart depois de gravar. Nenhuma página deste notebook escuta esses avisos; eles servem às LiveViews, que a trilha estuda mais adiante. É por eles que a célula de inicialização liga o Hello.PubSub.

Os testes gerados também usam o escopo: cada teste cria dois usuários, com user_scope_fixture/0, e confere que um não enxerga os carrinhos do outro.

Agora os itens:

mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer --no-scope
Trilha.pergunta(:no_scope)

O gerador avisa que o contexto já existe, pede confirmação e então cria o schema e a migration e injeta as funções novas no contexto:

You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20250205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex

priv/repo/migrations/20250205213410_create_cart_items.exs: a migration como o gerador a escreveu. O exercício logo abaixo a ajusta.

defmodule Hello.Repo.Migrations.CreateCartItems do
  use Ecto.Migration

  def change do
    create table(:cart_items) do
      add :price_when_carted, :decimal
      add :quantity, :integer
      add :cart_id, references(:carts, on_delete: :nothing)
      add :product_id, references(:products, on_delete: :nothing)

      timestamps(type: :utc_datetime)
    end

    create index(:cart_items, [:cart_id])
    create index(:cart_items, [:product_id])
  end
end

Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)

Sua vez: a migration dos itens

Antes de migrar, o guia faz quatro mudanças na célula acima. Faça-as, avalie a célula e só então rode o mix ecto.migrate, logo abaixo:

  1. price_when_carted passa a :decimal, precision: 15, scale: 6, null: false, como o preço dos produtos: dinheiro com precisão fixa e sempre presente.
  2. cart_id passa a on_delete: :delete_all: apagar um carrinho apaga os itens dele.
  3. product_id também passa a on_delete: :delete_all: apagar um produto do catálogo o tira de todos os carrinhos.
  4. O índice de :cart_id sai, e entra create unique_index(:cart_items, [:cart_id, :product_id]): o mesmo produto não pode aparecer duas vezes no mesmo carrinho.

As regras de on_delete ficam no banco de propósito. Assim nem o ShoppingCart nem o Catalog precisam de código para limpar itens órfãos, e os dois contextos continuam sem saber um do outro. O índice de :cart_id some porque o índice único começa por cart_id: um índice de várias colunas também atende às buscas pela primeira delas, e um índice a mais só deixaria as escritas mais lentas. É o mesmo raciocínio da tabela product_categories, no notebook 09.

Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

O log mostra as duas tabelas e os índices:

[info] == Running 20250205203128 Hello.Repo.Migrations.CreateCarts.change/0 forward
[info] create table carts
[info] create index carts_user_id_index
[info] == Migrated 20250205203128 in 0.0s
[info] == Running 20250205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward
[info] create table cart_items
[info] create index cart_items_product_id_index
[info] create index cart_items_cart_id_product_id_index
[info] == Migrated 20250205213410 in 0.0s

\d cart_items mostra o resultado. Confira o tipo de price_when_carted, os índices e as regras ON DELETE CASCADE das chaves estrangeiras:

Trilha.descrever("cart_items")
Trilha.conferir(:migracao_itens)
Trilha.solucao(:migracao_itens)

Se você migrou antes de editar, a tabela ficou com a versão gerada, e o Ecto não roda de novo uma migration que já rodou. Num projeto, você desfaria com mix ecto.rollback. Aqui, Trilha.desfazer_tabela("cart_items", 20250205213410) apaga a tabela e o registro da migration; depois, avalie a célula da migration e a do mix ecto.migrate.

As seções seguintes dependem do índice único. Sem ele, colocar um produto no carrinho falha com there is no unique or exclusion constraint matching the ON CONFLICT specification.

Dados que cruzam contextos

Na documentação: Cross-context data

Até aqui, Catalog e ShoppingCart não se conhecem. Mas um item de carrinho só existe se houver um produto: o ShoppingCart depende dos dados do Catalog.

Trilha.pergunta(:join_ou_api)

O guia apresenta as duas saídas. A primeira é o Catalog oferecer funções para o ShoppingCart buscar os produtos de que precisa, e o ShoppingCart juntar os dados em Elixir. A segunda é deixar o banco fazer o join. As duas são válidas; a primeira isola mais e custa mais código e mais consultas. Quando a dependência de dados é forte, como aqui, o join serve bem a muitas aplicações, e é o caminho do guia.

lib/hello/shopping_cart/cart.ex: o field :user_id, :id gerado vira belongs_to :user, Hello.Accounts.User, que define o mesmo campo user_id e mais a associação user. has_many :items liga o carrinho aos itens.

defmodule Hello.ShoppingCart.Cart do
  use Ecto.Schema
  import Ecto.Changeset

  schema "carts" do
    belongs_to :user, Hello.Accounts.User
    has_many :items, Hello.ShoppingCart.CartItem

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(cart, attrs, user_scope) do
    cart
    |> cast(attrs, [])
    |> validate_required([])
    |> put_change(:user_id, user_scope.user.id)
  end
end

lib/hello/shopping_cart/cart_item.ex: os campos cart_id e product_id gerados viram belongs_to. O segundo é a primeira dependência entre contextos: um schema do ShoppingCart apontando para um schema do Catalog. O guia faz isso de propósito: é o mínimo que o carrinho precisa saber para referenciar um produto. O changeset/2 desta célula ainda é o gerado; o exercício abaixo o muda.

defmodule Hello.ShoppingCart.CartItem do
  use Ecto.Schema
  import Ecto.Changeset

  schema "cart_items" do
    field :price_when_carted, :decimal
    field :quantity, :integer

    belongs_to :cart, Hello.ShoppingCart.Cart
    belongs_to :product, Hello.Catalog.Product

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(cart_item, attrs) do
    cart_item
    |> cast(attrs, [:price_when_carted, :quantity])
    |> validate_required([:price_when_carted, :quantity])
  end
end
Trilha.pergunta(:price_cast)

Sua vez: o changeset do item

Na célula do CartItem, mude o changeset/2 como o guia:

  1. tire :price_when_carted do cast/3 e do validate_required/3; o preço é responsabilidade do código, não de quem envia o formulário;
  2. acrescente validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100): quantidades de 0 a 99. O zero é aceito de propósito; mais adiante, ele significa "tire este item do carrinho".

Avalie a célula e teste:

alias Hello.ShoppingCart.CartItem

for params <- [%{"quantity" => "2", "price_when_carted" => "0.01"}, %{"quantity" => "100"}] do
  changeset = CartItem.changeset(%CartItem{}, params)
  %{params: params, changes: changeset.changes, errors: changeset.errors}
end

Depois da mudança, o primeiro changeset tem só quantity: 2 em changes, sem o preço forjado, e o segundo tem o erro must be less than 100.

Trilha.conferir(:item_changeset)
Trilha.solucao(:item_changeset)

Funções do carrinho

Na documentação: Adding Cart functions

Os geradores são só o ponto de partida. O contexto precisa de funções com nomes que digam o que fazem: garantir que todo usuário tenha um carrinho, pôr e tirar itens, atualizar quantidades, somar preços.

lib/hello/shopping_cart.ex: o contexto gerado, com as funções que o guia acrescenta. O guia chega a elas aos poucos, em três passos; aqui elas já estão todas na célula, e o texto abaixo dela segue a ordem do guia. A exceção é update_cart/3, que ainda é a gerada: ela é o exercício de mais adiante.

defmodule Hello.ShoppingCart do
  @moduledoc """
  The ShoppingCart context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Catalog
  alias Hello.ShoppingCart.{Cart, CartItem}
  alias Hello.Accounts.Scope

  @doc """
  Subscribes to scoped notifications about any cart changes.

  The broadcasted messages match the pattern:

    * {:created, %Cart{}}
    * {:updated, %Cart{}}
    * {:deleted, %Cart{}}

  """
  def subscribe_carts(%Scope{} = scope) do
    key = scope.user.id

    Phoenix.PubSub.subscribe(Hello.PubSub, "user:#{key}:carts")
  end

  defp broadcast_cart(%Scope{} = scope, message) do
    key = scope.user.id

    Phoenix.PubSub.broadcast(Hello.PubSub, "user:#{key}:carts", message)
  end

  @doc """
  Returns the list of carts.

  ## Examples

      iex> list_carts(scope)
      [%Cart{}, ...]

  """
  def list_carts(%Scope{} = scope) do
    Repo.all_by(Cart, user_id: scope.user.id)
  end

  @doc """
  Gets a single cart.

  Raises `Ecto.NoResultsError` if the Cart does not exist.

  ## Examples

      iex> get_cart!(scope, 123)
      %Cart{}

      iex> get_cart!(scope, 456)
      ** (Ecto.NoResultsError)

  """
  def get_cart!(%Scope{} = scope, id) do
    Repo.get_by!(Cart, id: id, user_id: scope.user.id)
  end

  def get_cart(%Scope{} = scope) do
    Repo.one(
      from(c in Cart,
        where: c.user_id == ^scope.user.id,
        left_join: i in assoc(c, :items),
        left_join: p in assoc(i, :product),
        order_by: [asc: i.inserted_at],
        preload: [items: {i, product: p}]
      )
    )
  end

  @doc """
  Creates a cart.

  ## Examples

      iex> create_cart(scope, %{field: value})
      {:ok, %Cart{}}

      iex> create_cart(scope, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_cart(%Scope{} = scope, attrs) do
    with {:ok, cart = %Cart{}} <-
           %Cart{}
           |> Cart.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_cart(scope, {:created, cart})
      {:ok, get_cart(scope)}
    end
  end

  def add_item_to_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
    true = cart.user_id == scope.user.id
    product = Catalog.get_product!(product_id)

    %CartItem{quantity: 1, price_when_carted: product.price}
    |> CartItem.changeset(%{})
    |> Ecto.Changeset.put_assoc(:cart, cart)
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Repo.insert(
      on_conflict: [inc: [quantity: 1]],
      conflict_target: [:cart_id, :product_id]
    )
  end

  def remove_item_from_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
    true = cart.user_id == scope.user.id

    {1, _} =
      Repo.delete_all(
        from(i in CartItem,
          where: i.cart_id == ^cart.id,
          where: i.product_id == ^product_id
        )
      )

    {:ok, get_cart(scope)}
  end

  def total_item_price(%CartItem{} = item) do
    Decimal.mult(item.product.price, item.quantity)
  end

  def total_cart_price(%Cart{} = cart) do
    Enum.reduce(cart.items, 0, fn item, acc ->
      item
      |> total_item_price()
      |> Decimal.add(acc)
    end)
  end

  @doc """
  Updates a cart.

  ## Examples

      iex> update_cart(scope, cart, %{field: new_value})
      {:ok, %Cart{}}

      iex> update_cart(scope, cart, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
    true = cart.user_id == scope.user.id

    with {:ok, cart = %Cart{}} <-
           cart
           |> Cart.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_cart(scope, {:updated, cart})
      {:ok, cart}
    end
  end

  @doc """
  Deletes a cart.

  ## Examples

      iex> delete_cart(scope, cart)
      {:ok, %Cart{}}

      iex> delete_cart(scope, cart)
      {:error, %Ecto.Changeset{}}

  """
  def delete_cart(%Scope{} = scope, %Cart{} = cart) do
    true = cart.user_id == scope.user.id

    with {:ok, cart = %Cart{}} <-
           Repo.delete(cart) do
      broadcast_cart(scope, {:deleted, cart})
      {:ok, cart}
    end
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking cart changes.

  ## Examples

      iex> change_cart(scope, cart)
      %Ecto.Changeset{data: %Cart{}}

  """
  def change_cart(%Scope{} = scope, %Cart{} = cart, attrs \\ %{}) do
    true = cart.user_id == scope.user.id

    Cart.changeset(cart, attrs, scope)
  end

  @doc """
  Returns the list of cart_items.

  ## Examples

      iex> list_cart_items()
      [%CartItem{}, ...]

  """
  def list_cart_items do
    Repo.all(CartItem)
  end

  @doc """
  Gets a single cart_item.

  Raises `Ecto.NoResultsError` if the Cart item does not exist.

  ## Examples

      iex> get_cart_item!(123)
      %CartItem{}

      iex> get_cart_item!(456)
      ** (Ecto.NoResultsError)

  """
  def get_cart_item!(id), do: Repo.get!(CartItem, id)

  @doc """
  Creates a cart_item.

  ## Examples

      iex> create_cart_item(%{field: value})
      {:ok, %CartItem{}}

      iex> create_cart_item(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_cart_item(attrs) do
    %CartItem{}
    |> CartItem.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a cart_item.

  ## Examples

      iex> update_cart_item(cart_item, %{field: new_value})
      {:ok, %CartItem{}}

      iex> update_cart_item(cart_item, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_cart_item(%CartItem{} = cart_item, attrs) do
    cart_item
    |> CartItem.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a cart_item.

  ## Examples

      iex> delete_cart_item(cart_item)
      {:ok, %CartItem{}}

      iex> delete_cart_item(cart_item)
      {:error, %Ecto.Changeset{}}

  """
  def delete_cart_item(%CartItem{} = cart_item) do
    Repo.delete(cart_item)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking cart_item changes.

  ## Examples

      iex> change_cart_item(cart_item)
      %Ecto.Changeset{data: %CartItem{}}

  """
  def change_cart_item(%CartItem{} = cart_item, attrs \\ %{}) do
    CartItem.changeset(cart_item, attrs)
  end
end

As funções novas, na ordem do guia:

  • get_cart/1 busca o carrinho do usuário do escopo numa consulta só: dois left_join (o carrinho com os itens, os itens com os produtos) e um preload que usa as linhas desses joins para montar cart.items e item.product. É o join entre contextos que o guia escolheu. O left_join mantém o carrinho mesmo sem nenhum item; order_by deixa os itens na ordem em que entraram.
  • create_cart/2 é a gerada com uma mudança: devolve get_cart(scope) em vez do carrinho recém-inserido, para que quem chamou já receba items: [] carregado.
  • add_item_to_cart/3 confere o dono, busca o produto pelo contexto Catalog (um contexto pode chamar outro) e insere um item com quantidade 1 e o preço do momento.
  • remove_item_from_cart/3 apaga, com Repo.delete_all/1, o item daquele produto naquele carrinho.

Uma diferença em relação ao guia: no trecho de create_cart/2, o guia chama broadcast(scope, {:created, cart}), mas a função privada que o gerador do 1.8.16 cria se chama broadcast_cart/2. Copiado ao pé da letra, o trecho não compila (undefined function broadcast/2). A célula acima usa o nome gerado, como o próprio guia faz mais adiante, em update_cart/3.

Olhe de novo o Repo.insert de add_item_to_cart/3, com as opções on_conflict e conflict_target.

Trilha.pergunta(:upsert)

Esse insert é um upsert (insert ou update). Se o produto ainda não está no carrinho, entra uma linha nova. Se já está, o índice único [cart_id, product_id] da migration recusa a linha repetida, e on_conflict: [inc: [quantity: 1]] transforma a recusa num incremento. Como tudo acontece num comando só, dois cliques simultâneos não perdem uma unidade, pelo mesmo motivo do inc_page_views/1 do notebook 08.

Antes dos controllers, o router. O guia acrescenta à pipeline :browser um plug que garante um carrinho a todo usuário logado, e cria as rotas do carrinho num bloco que exige login.

lib/hello_web/router.ex: o router da seção de autenticação, com o plug :fetch_current_cart e o bloco do carrinho.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
    plug :fetch_current_cart
  end

  alias Hello.ShoppingCart

  defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
    if cart = ShoppingCart.get_cart(scope) do
      assign(conn, :cart, cart)
    else
      {:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
      assign(conn, :cart, new_cart)
    end
  end

  defp fetch_current_cart(conn, _opts), do: conn

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/products", ProductController
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    resources "/cart_items", CartItemController, only: [:create, :delete]

    get "/cart", CartController, :show
    put "/cart", CartController, :update
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

fetch_current_cart/2 tem duas cláusulas. A primeira só casa quando há alguém logado (current_scope diferente de nil): busca o carrinho e, se não houver, cria um. A segunda deixa o conn como está, para quem não fez login. Um plug de função definido no próprio router funciona como os plugs do UserAuth: recebe o conn e as opções e devolve o conn.

O trecho de router do guia mostra a pipeline :browser de versões antigas do Phoenix, com plug :put_root_layout, html: {HelloWeb.LayoutView, :root}. Num projeto 1.8 a linha é {HelloWeb.Layouts, :root}, como acima.

As rotas novas usam o bloco com :require_authenticated_user: resources com only: [:create, :delete] cria POST /cart_items e DELETE /cart_items/:id; GET /cart mostra o carrinho; PUT /cart recebe o formulário de quantidades. A célula avisa que HelloWeb.CartItemController.init/1 e HelloWeb.CartController.init/1 não existem: o router aponta para controllers que você ainda vai definir.

lib/hello_web/controllers/cart_item_controller.ex: pôr e tirar itens. As duas actions só repassam o pedido ao contexto, com o escopo e o carrinho que os plugs deixaram em conn.assigns. Os nomes add_item_to_cart e remove_item_from_cart dizem o que acontece sem que o controller saiba como.

defmodule HelloWeb.CartItemController do
  use HelloWeb, :controller

  alias Hello.ShoppingCart

  def create(conn, %{"product_id" => product_id}) do
    case ShoppingCart.add_item_to_cart(conn.assigns.current_scope, conn.assigns.cart, product_id) do
      {:ok, _item} ->
        conn
        |> put_flash(:info, "Item added to your cart")
        |> redirect(to: ~p"/cart")

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error adding the item to your cart")
        |> redirect(to: ~p"/cart")
    end
  end

  def delete(conn, %{"id" => product_id}) do
    {:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.current_scope, conn.assigns.cart, product_id)
    redirect(conn, to: ~p"/cart")
  end
end

Sua vez: o botão "Add to cart"

A página do produto precisa de um botão que envie POST /cart_items?product_id=…. A célula abaixo é o ProductHTML do topo, que agora ganha uma versão nova. A do topo fica como estava, porque compila antes de existir a rota /cart_items.

Em show/1, dentro do slot :actions do <.header>, depois do botão "Edit product", acrescente:

<.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
  Add to cart
</.button>

Com href, o <.button> dos CoreComponents vira um <.link>. Com method="post", o link ganha os atributos data-method="post" e data-to, e o phoenix_html.js (servido pelo endpoint) transforma o clique num formulário enviado por POST, com o token CSRF. Sem method, um link sempre faz GET.

defmodule HelloWeb.ProductHTML do
  use HelloWeb, :html

  # Sua vez: o botão "Add to cart" entra em show/1, no slot :actions do <.header>.

  def category_opts(changeset) do
    existing_ids =
      changeset
      |> Ecto.Changeset.get_change(:categories, [])
      |> Enum.map(& &1.data.id)

    for cat <- Hello.Catalog.list_categories() do
      [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
    end
  end

  # product_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Listing Products
        <:actions>
          <.button href={~p"/products/new"}>
            <.icon name="hero-plus" /> New Product
          </.button>
        </:actions>
      </.header>

      <.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
        <:col :let={product} label="Title">{product.title}</:col>
        <:col :let={product} label="Description">{product.description}</:col>
        <:col :let={product} label="Price">{product.price}</:col>
        <:col :let={product} label="Views">{product.views}</:col>
        <:action :let={product}>
          <div class="sr-only">
            <.link navigate={~p"/products/#{product}"}>Show</.link>
          </div>
          <.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
        </:action>
        <:action :let={product}>
          <.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # product_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Product {@product.id}
        <:subtitle>This is a product record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/products"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit product
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@product.title}</:item>
        <:item title="Description">{@product.description}</:item>
        <:item title="Price">{@product.price}</:item>
        <:item title="Views">{@product.views}</:item>
        <:item title="Categories">
          <ul>
            <li :for={cat <- @product.categories}>{cat.title}</li>
          </ul>
        </:item>
      </.list>
    </Layouts.app>
    """
  end

  # product_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        New Product
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  # product_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Edit Product {@product.id}
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  @doc """
  Renders a product form.

  The form is defined in the template at
  product_html/product_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # product_html/product_form.html.heex
  def product_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <.input field={f[:description]} type="text" label="Description" />
      <.input field={f[:price]} type="number" label="Price" step="any" />
      <.input field={f[:views]} type="number" label="Views" />

      <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />

      <footer>
        <.button variant="primary">Save Product</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end

Veja a tag que o template gerou:

{:ok, conn} = Trilha.visitar("bia", :get, "/products/1")
Regex.run(~r{<a\b[^>]*>\s*Add to cart}, conn.resp_body)
Trilha.conferir(:botao)
Trilha.solucao(:botao)

Erro como conteúdo: o controller que ainda não existe

Primeiro, alguém sem login tenta pôr um produto no carrinho:

Trilha.navegar("visitante", :post, "/cart_items?product_id=1")

O plug :require_authenticated_user parou a requisição antes do controller (o log diz Phoenix.Router halted in :require_authenticated_user/2) e redirecionou para /users/log-in com a flash "You must log in to access this page." Num GET, ele também guardaria o caminho na sessão (:user_return_to), para voltar a ele depois do login; num POST, não guarda.

Agora a Bia, que está logada, faz o mesmo:

Trilha.navegar("bia", :post, "/cart_items?product_id=1")

Como no guia, "está funcionando, mais ou menos". Leia o log da célula de cima para baixo: o POST /cart_items passou pelas pipelines [:browser, :require_authenticated_user], o CartItemController.create/2 inseriu o item (o SQL termina em ON CONFLICT ("cart_id","product_id") DO UPDATE SET "quantity" = c0."quantity" + $7; o begin e o commit em volta aparecem porque o changeset tem associações, e o Ecto põe o insert numa transação) e redirecionou para /cart. No GET /cart, o carrinho foi buscado, e então veio a exceção: function HelloWeb.CartController.init/1 is undefined (module HelloWeb.CartController is not available). O router despachou para um controller que não existe.

O item foi gravado:

Trilha.consultar("SELECT * FROM cart_items")

lib/hello_web/controllers/cart_controller.ex: a página do carrinho. O template vai precisar de um changeset do carrinho, para o formulário de quantidades; ShoppingCart.change_cart/3, gerada, devolve um. O carrinho já está em conn.assigns.cart, posto pelo plug do router.

defmodule HelloWeb.CartController do
  use HelloWeb, :controller

  alias Hello.ShoppingCart

  def show(conn, _params) do
    render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
  end
end

lib/hello_web/controllers/cart_html.ex e o template cart_html/show.html.heex

defmodule HelloWeb.CartHTML do
  use HelloWeb, :html

  alias Hello.ShoppingCart

  def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"

  # cart_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        My Cart
        <:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
      </.header>

      <div :if={@cart.items !== []}>
        <.form :let={f} for={@changeset} action={~p"/cart"}>
          <.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
            <.input field={item_form[:quantity]} type="number" label={item.product.title} />
            {currency_to_str(ShoppingCart.total_item_price(item))}
          </.inputs_for>
          <.button>Update cart</.button>
        </.form>
        <b>Total</b>: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
      </div>

      <.button navigate={~p"/products"}>Back to products</.button>
    </Layouts.app>
    """
  end
end

O template:

  • mostra "Your cart is empty" quando @cart.items é vazio. @cart não foi passado pelo render/3: os assigns do conn, como cart e current_scope, chegam ao template junto com os do render;
  • monta um formulário a partir do changeset, com <.inputs_for> para os itens: um campo de quantidade por item, com o título do produto como rótulo. %{data: item} tira do formulário de cada item o CartItem original, com o produto carregado;
  • mostra o total de cada item e o do carrinho com total_item_price/1 e total_cart_price/1. O guia as escreve neste ponto; aqui elas já estavam na célula do ShoppingCart. As duas usam Decimal.mult/2 e Decimal.add/2: dinheiro não se calcula com float;
  • formata os valores com currency_to_str/1, que arredonda para duas casas e põe o cifrão.
Trilha.navegar("bia", :get, "/cart")

A Bia clica de novo em "Add to cart" no mesmo produto. É a pergunta do upsert:

Trilha.navegar("bia", :post, "/cart_items?product_id=1")

A quantidade passou a 2, e a tabela continua com uma linha só:

Trilha.consultar("SELECT id, cart_id, product_id, quantity, price_when_carted FROM cart_items ORDER BY id")

Ela põe também o outro produto:

Trilha.navegar("bia", :post, "/cart_items?product_id=2")
Trilha.consultar("SELECT id, cart_id, product_id, quantity, price_when_carted FROM cart_items ORDER BY id")

Seguindo a ordem do notebook, o item novo ficou com id 3, e não 2. O PostgreSQL tira o próximo número da sequência antes de tentar o insert; quando o upsert vira update, o número reservado não é devolvido. Buracos na sequência de ids são normais e não indicam problema.

Erro como conteúdo: a action que falta

O carrinho tem um formulário de quantidades. Veja o que ele enviaria:

Trilha.formulario("bia", "/cart")

Quatro detalhes:

  • O formulário é POST /cart, mas leva o campo escondido _method com o valor put. O <.form> faz isso sozinho quando o changeset é de um registro que já existe no banco. Navegadores só enviam formulários com GET e POST; o Plug.MethodOverride, no endpoint, troca o método para PUT antes de a requisição chegar ao router.
  • Para cada item, <.inputs_for> gerou campos escondidos: o id (cart[items][0][id]), que mais adiante permite ao Ecto saber qual item cada quantidade atualiza, e _persistent_id, que o LiveView usa para acompanhar itens de formulários dinâmicos e que o Ecto ignora.
  • Os itens aparecem na ordem em que entraram no carrinho (order_by: [asc: i.inserted_at]), e inserted_at tem precisão de segundos. Dois itens que entraram no mesmo segundo podem trocar de posição entre uma página e outra. Por isso as células abaixo preenchem os campos pelo rótulo que aparece na tela, como você faria olhando a página, e não pela posição.
  • Não há campo de preço. Mesmo assim, quem quisesse poderia mandar um; é o que a pergunta sobre o cast discutiu.

A Bia muda a quantidade de "Metaprogramming Elixir" para 3 e clica em "Update cart":

Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3"})

A rota existe, mas o controller não tem a action: function HelloWeb.CartController.update/2 is undefined or private. No guia, o log mostra POST /cart, que é o que o navegador envia; o router recebe o PUT que o Plug.MethodOverride produziu.

lib/hello_web/controllers/cart_controller.ex: agora com update/2. Se o contexto atualizar, volta para o carrinho; se não, volta com uma flash de erro.

defmodule HelloWeb.CartController do
  use HelloWeb, :controller

  alias Hello.ShoppingCart

  def show(conn, _params) do
    render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
  end

  def update(conn, %{"cart" => cart_params}) do
    case ShoppingCart.update_cart(conn.assigns.current_scope, conn.assigns.cart, cart_params) do
      {:ok, _cart} ->
        redirect(conn, to: ~p"/cart")

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error updating your cart")
        |> redirect(to: ~p"/cart")
    end
  end
end

Envie de novo:

Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3"})

Sem erro, mas a quantidade continua 2. O update_cart/3 gerado só faz cast dos campos do próprio Cart, que são nenhum (cast(attrs, [])); os dados dos itens chegam e são ignorados.

Sua vez: update_cart/3

O guia troca o update_cart/3 gerado por uma versão que atualiza os itens. Ela tem três partes:

  1. O changeset. Comece como a gerada, com Cart.changeset(attrs, scope), e encadeie Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2). cast_assoc/3 aplica os parâmetros de cada item ao CartItem correspondente, pelo id escondido do formulário. Os itens precisam estar carregados no carrinho, e estão, porque o plug usou get_cart/1. Cada item passa pelo changeset/2 do exercício anterior, então uma quantidade 100 invalida o carrinho inteiro.
  2. A transação. Dentro de Repo.transact(fn -> ... end), rode Repo.update(changeset) e, se der certo, um Repo.delete_all/1 dos itens deste carrinho com quantity == 0. A função passada a Repo.transact/2 devolve {:ok, valor}, e a transação é confirmada, ou {:error, motivo}, e tudo é desfeito. Um with resolve: o {:error, changeset} do Repo.update sai dele direto e desfaz a transação.
  3. O aviso. Depois da transação, se ela deu certo, chame broadcast_cart(scope, {:updated, cart}) e devolva {:ok, cart}; se não, devolva o erro como veio.

Mantenha a primeira linha, true = cart.user_id == scope.user.id.

Na célula do ShoppingCart, troque o update_cart/3 gerado pela sua versão e avalie a célula. O CartController não precisa mudar.

Depois, a Bia põe 3 unidades de "Metaprogramming Elixir" e zera "Elixir Flashcards":

Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3", "Elixir Flashcards" => "0"})

O carrinho fica só com "Metaprogramming Elixir", 3 unidades, $45.00. Agora uma quantidade fora do limite:

Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "100"})

O log mostra a transação aberta e desfeita (begin e rollback), a flash "There was an error updating your cart" aparece, e o carrinho continua como estava.

Trilha.conferir(:update_cart)
Trilha.solucao(:update_cart)

Cada um com o seu carrinho

O Caio entra na loja:

Trilha.navegar("caio", :post, "/users/log-in", %{"user" => %{"email" => "caio@example.com"}})
Trilha.navegar("caio", :get, "/cart")

O carrinho dele está vazio. O plug criou um carrinho novo no primeiro acesso do Caio, e get_cart/1 só busca o carrinho do usuário do escopo. Na tabela, os números pulados (se houver) são de carrinhos que as conferências dos exercícios criaram e apagaram:

Trilha.consultar("SELECT id, user_id, inserted_at FROM carts ORDER BY id")

E se algum código errado misturar os dois? Pegue os escopos e o carrinho da Bia:

escopo_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))
escopo_do_caio = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("caio@example.com"))
carrinho_da_bia = Hello.ShoppingCart.get_cart(escopo_da_bia)

%{dono_do_carrinho: carrinho_da_bia.user_id, caio: escopo_do_caio.user.id}
Trilha.pergunta(:true_match)
Hello.ShoppingCart.update_cart(escopo_do_caio, carrinho_da_bia, %{})

MatchError com o valor false: a primeira linha de update_cart/3 não deixou o carrinho da Bia ser alterado com o escopo do Caio. E pelo id, com a função que busca filtrando pelo escopo:

Hello.ShoppingCart.get_cart!(escopo_do_caio, carrinho_da_bia.id)

Ecto.NoResultsError: o where da consulta pede o id do carrinho da Bia e o user_id do Caio, e nenhuma linha tem os dois. Numa requisição, o phoenix_ecto faz essa exceção virar uma resposta 404, como se o carrinho não existisse. As duas células acima falham de propósito.

O preço de quando o item entrou

Trilha.pergunta(:preco_atual)

A loja aumenta o preço do produto 1. (Sem category_ids nos parâmetros, o change_product/2 do notebook 09 deixa o produto sem categorias; o produto 1 não tem nenhuma, então nada se perde.)

{:ok, produto} = Hello.Catalog.update_product(Hello.Catalog.get_product!(1), %{"price" => "20"})
produto.price
Trilha.navegar("bia", :get, "/cart")

O carrinho da Bia passou a $60.00 sem que ela fizesse nada. A tabela cart_items ainda tem 15.000000 em price_when_carted, mas ninguém a usa.

Sua vez: avisar que o preço mudou

Na célula do CartHTML, faça a página avisar quando o preço de um item mudou desde que ele entrou no carrinho: ao lado do total do item, mostre (was $15.00), com o preço guardado em price_when_carted, só quando ele for diferente do preço atual (item.product.price). Compare os dois com Decimal.equal?/2, e não com ==: 15.000000 e 15 são o mesmo valor, mas structs Decimal diferentes.

Trilha.navegar("bia", :get, "/cart")
Trilha.conferir(:preco_mudou)
Trilha.solucao(:preco_mudou)

Uma loja real decidiria o que fazer com a diferença: cobrar o preço antigo, o novo, ou pedir confirmação. O ponto do guia é que, sem guardar o preço do momento, nem daria para perceber. Volte o preço ao original:

{:ok, produto} = Hello.Catalog.update_product(Hello.Catalog.get_product!(1), %{"price" => "15"})
produto.price

Tirando um item

A última action do CartItemController é delete. Na rota DELETE /cart_items/:id, o :id é o id do produto, não o do item: remove_item_from_cart/3 recebe o carrinho e o produto. Um link com method="delete" faria esta requisição no navegador.

Trilha.navegar("bia", :delete, "/cart_items/1")

O carrinho ficou vazio. remove_item_from_cart/3 exige que exatamente uma linha seja apagada ({1, _} = ...); pedir para tirar um produto que não está no carrinho é tratado como bug e levanta MatchError.

Sem roteiro: a lista de desejos do Brechó

Agora o mesmo assunto sem instruções passo a passo, no Brechó, o mini-marketplace da trilha.

O Brechó até aqui

As células abaixo trazem o Brechó como o desafio do notebook 08 o deixou: a tabela produtos, o contexto Hello.Vitrine e a página /produtos. Avalie-as em ordem; o router é o do carrinho, com as rotas do Brechó acrescentadas.

priv/repo/migrations/20250320000000_create_produtos.exs

defmodule Hello.Repo.Migrations.CreateProdutos do
  use Ecto.Migration

  def change do
    create table(:produtos) do
      add :nome, :string, null: false
      add :preco_centavos, :integer, null: false
      add :vendido, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex

defmodule Hello.Vitrine.Produto do
  use Ecto.Schema
  import Ecto.Changeset

  schema "produtos" do
    field :nome, :string
    field :preco_centavos, :integer
    field :vendido, :boolean, default: false

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(produto, attrs) do
    produto
    |> cast(attrs, [:nome, :preco_centavos])
    |> validate_required([:nome, :preco_centavos])
    |> validate_number(:preco_centavos, greater_than: 0)
  end
end

defmodule Hello.Vitrine do
  @moduledoc """
  A vitrine do Brechó: o que está à venda e a venda de cada peça.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Vitrine.Produto

  def listar_disponiveis do
    Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
  end

  def buscar_produto!(id), do: Repo.get!(Produto, id)

  def cadastrar_produto(attrs) do
    %Produto{}
    |> Produto.changeset(attrs)
    |> Repo.insert()
  end

  def change_produto(%Produto{} = produto, attrs \\ %{}) do
    Produto.changeset(produto, attrs)
  end

  @doc """
  Marca a peça como vendida, se ninguém a vendeu antes.

  A verificação e a mudança acontecem num só UPDATE, então duas vendas
  simultâneas da mesma peça nunca dão certo ao mesmo tempo.
  """
  def vender(%Produto{id: id}) do
    consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

    case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
      {1, [produto]} -> {:ok, produto}
      {0, []} -> {:error, :ja_vendido}
    end
  end
end

lib/hello_web/router.ex: o router do carrinho, com as rotas do Brechó. As rotas da lista de desejos entram no bloco marcado.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
    plug :fetch_current_cart
  end

  alias Hello.ShoppingCart

  defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
    if cart = ShoppingCart.get_cart(scope) do
      assign(conn, :cart, cart)
    else
      {:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
      assign(conn, :cart, new_cart)
    end
  end

  defp fetch_current_cart(conn, _opts), do: conn

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/products", ProductController

    get "/produtos", ProdutoController, :index
    post "/produtos", ProdutoController, :create
    post "/produtos/:id/vender", ProdutoController, :vender
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    resources "/cart_items", CartItemController, only: [:create, :delete]

    get "/cart", CartController, :show
    put "/cart", CartController, :update

    # Sem roteiro: as rotas da lista de desejos entram aqui.
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

lib/hello_web/controllers/produto_controller.ex e produto_html.ex

defmodule HelloWeb.ProdutoController do
  use HelloWeb, :controller

  alias Hello.Vitrine

  def index(conn, _params) do
    render(conn, :index, produtos: Vitrine.listar_disponiveis())
  end

  def create(conn, %{"produto" => attrs}) do
    case Vitrine.cadastrar_produto(attrs) do
      {:ok, produto} ->
        conn |> put_status(201) |> json(%{id: produto.id})

      {:error, changeset} ->
        erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        conn |> put_status(422) |> json(%{errors: erros})
    end
  end

  def vender(conn, %{"id" => id}) do
    case Vitrine.vender(Vitrine.buscar_produto!(id)) do
      {:ok, produto} ->
        conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")

      {:error, :ja_vendido} ->
        conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
    end
  end
end

defmodule HelloWeb.ProdutoHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>Vitrine do Brechó</.header>
      <.table id="produtos" rows={@produtos}>
        <:col :let={produto} label="Peça">{produto.nome}</:col>
        <:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
        <:action :let={produto}>
          <.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end
end

Três peças na vitrine:

for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
  {:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end

Trilha.navegar("bia", :get, "/produtos")

O desafio

No Brechó, quem está logado pode guardar peças numa lista de desejos, para decidir depois. Cada lista é de um usuário, como o carrinho. Faça:

  1. Uma migration com a versão 20250322000000, que crie a tabela desejos com user_id (referência a users) e produto_id (referência a produtos), as duas com on_delete: :delete_all, os timestamps e um índice único de [:user_id, :produto_id]. Rode o mix ecto.migrate.
  2. Um contexto Hello.Desejos, com o schema Hello.Desejos.Desejo (belongs_to :user e belongs_to :produto, Hello.Vitrine.Produto) e três funções que recebem o escopo, como as geradas:
    • desejar(scope, produto_id) guarda a peça na lista; guardar a mesma peça de novo não duplica a linha nem dá erro, e devolve {:ok, _} nos dois casos;
    • listar(scope) devolve os desejos do usuário, cada um com o produto carregado em desejo.produto;
    • remover(scope, produto_id) tira a peça da lista do usuário.
  3. Duas rotas, só para quem fez login: POST /desejos?produto_id=… guarda a peça e redireciona para GET /desejos, uma página que lista o nome das peças guardadas.

Pense em quem é o dono de cada linha e em onde esse dono é conferido. O desafio usa as peças do Brechó, mas a pergunta da seção "Dados que cruzam contextos" vale aqui também: Desejos depende de Vitrine e de Accounts.

Use as células abaixo e edite a do router, acima.

# priv/repo/migrations/20250322000000_create_desejos.exs
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/desejos/desejo.ex e lib/hello/desejos.ex
# lib/hello_web/controllers/desejo_controller.ex e desejo_html.ex

Para testar como a Bia (antes de resolver, a resposta é um erro de rota):

Trilha.navegar("bia", :post, "/desejos?produto_id=1")

A célula do router avisa que HelloWeb.ProdutoController.init/1 não existe: esse controller vem na célula logo depois dela. Depois que você acrescentar as rotas dos desejos, o aviso também cita HelloWeb.DesejoController, até a célula do controller ser avaliada; avalie o router de novo depois dela, e os avisos somem.

Trilha.conferir(:brecho)
Trilha.solucao(:brecho)

No seu projeto

Repita o guia num projeto gerado por mix phx.new hello, no estado em que o notebook 09 o deixou. A correspondência entre as células e os arquivos e comandos é esta:

Neste notebook No projeto
Autenticação reduzida (Accounts, User, Scope, UserAuth, UserSession*) mix phx.gen.auth Accounts User users (responda n para usar controllers), mix deps.get e mix ecto.migrate
Login com Trilha.navegar(quem, :post, "/users/log-in", ...) Cadastro em /users/register, e-mail de confirmação em /dev/mailbox
Células das migrations + Trilha.migracao/2 Arquivos que mix phx.gen.context cria em priv/repo/migrations/
Células do ShoppingCart, Cart e CartItem lib/hello/shopping_cart.ex e lib/hello/shopping_cart/
Uma célula nova do router a cada mudança lib/hello_web/router.ex, editado no lugar
Trilha.desfazer_tabela/2 mix ecto.rollback
Trilha.navegar, Trilha.formulario, Trilha.enviar O navegador, em http://localhost:4000

Roteiro:

  1. Rode mix phx.gen.auth Accounts User users, responda n, rode mix deps.get e mix ecto.migrate. A migration gerada cria a extensão citext do PostgreSQL, para comparar e-mails sem diferenciar maiúsculas. No Fedora, ela vem num pacote à parte; sem ele, o mix ecto.migrate falha com extension "citext" is not available. Instale com sudo dnf install postgresql-contrib e rode o mix ecto.migrate de novo. Depois, leia o bloco config :hello, :scopes em config/config.exs e o lib/hello/accounts/scope.ex. Crie uma conta em /users/register e confirme pelo /dev/mailbox.
  2. Rode mix phx.gen.context ShoppingCart Cart carts e leia o contexto gerado: todas as funções recebem o escopo. Leia também test/hello/shopping_cart_test.exs, que testa um usuário contra outro.
  3. Rode mix phx.gen.context ShoppingCart CartItem cart_items cart_id:references:carts product_id:references:products price_when_carted:decimal quantity:integer --no-scope, ajuste a migration como no exercício e rode mix ecto.migrate.
  4. Faça as mudanças do guia nos schemas, no contexto, no router, nos controllers e nos templates. No create_cart/2, use broadcast_cart/2, o nome que o gerador criou.
  5. Ponha produtos no carrinho pelo navegador, mude quantidades, zere uma delas. Abra uma janela anônima, crie outra conta e confira que os carrinhos são separados.
  6. Rode mix test test/hello/shopping_cart_test.exs. Logo depois dos geradores, 2 dos 18 testes já falham: os de "invalid data" de create_cart/2 e update_cart/3 esperam um erro, mas um Cart sem campos não tem o que invalidar. Depois das mudanças do guia, falham 11: as fixtures de itens mandam price_when_carted, que saiu do cast (e a coluna não aceita NULL), e create_cart/2 passou a devolver o carrinho com os itens carregados, diferente do que list_carts/1 e get_cart!/2 devolvem. Ajustar esses testes é um bom exercício: leia cada falha e decida se o teste ou o código está errado.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que é um escopo, por que o Cart tem escopo e o CartItem não, e o que acontece entre o clique em "Add to cart" e a linha nova em cart_items. Depois compare com os pontos abaixo.

  • Autenticação diz quem é o usuário; autorização diz o que ele pode acessar. O phx.gen.auth cuida da primeira e deixa o escopo para a segunda.
  • O escopo (%Scope{user: user}) chega aos controllers em conn.assigns.current_scope, pelo plug fetch_current_scope_for_user. Os contextos o recebem como primeiro argumento e filtram cada consulta pelo dono.
  • Com um escopo padrão em config :hello, :scopes, os geradores criam user_id, changeset/3 com put_change(:user_id, ...), consultas filtradas e asserções true = recurso.user_id == scope.user.id. --no-scope desliga isso para recursos que chegam ao dono por outro caminho, como os itens pelo carrinho.
  • Um contexto pode depender dos dados de outro (belongs_to :product, Hello.Catalog.Product, joins) e chamar as funções de outro (Catalog.get_product!/1) sem conhecer as regras dele.
  • Restrições do banco fazem parte do desenho: on_delete: :delete_all limpa itens órfãos sem código nos contextos, e o índice único permite o upsert (on_conflict + conflict_target), atômico.
  • O preço do momento (price_when_carted) nunca vem da entrada do usuário: sai do cast e é posto pelo código.
  • cast_assoc/3 atualiza os itens a partir do formulário, guiado pelo id escondido que <.inputs_for> gera. Repo.transact/2 agrupa passos que precisam acontecer juntos.
  • Nomes de funções que dizem a intenção (add_item_to_cart, remove_item_from_cart) deixam os controllers curtos e permitem escrever a camada web antes dos detalhes do contexto.

Próximo guia da trilha: Bringing It Home, que fecha o pedido num terceiro contexto, Orders, a partir do carrinho deste notebook.