Powered by AppSignal & Oban Pro

Trilha Phoenix · Fechando o pedido

11_fechando_o_pedido.livemd

Trilha Phoenix · Fechando o pedido

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, 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

  # --- 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 "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

  @doc "O id do pedido mais recente do usuário com este e-mail."
  def ultimo_pedido(email) do
    case sql("SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.email = $1 ORDER BY o.id DESC LIMIT 1", [email]).rows do
      [[id]] -> id
      [] -> raise "#{email} ainda não tem nenhum pedido. Faça os exercícios das seções anteriores e complete um pedido."
    end
  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 "bia"; 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 <> "**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

  @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

  # --- 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_mora: %{
        texto:
          "a Bia vai fechar a compra. O pedido precisa guardar o preço de cada item no momento do pagamento, e as regras são outras: um item sem estoque pode ficar no carrinho, mas não pode ser comprado. Onde deve morar o código que completa um pedido?",
        opcoes: [
          a: "No `Catalog`, porque o pedido é feito de produtos.",
          b: "No `ShoppingCart`, porque o pedido nasce do carrinho.",
          c: "Em um contexto novo, `Orders`, com dados e regras próprios.",
          d: "No `OrderController`, porque completar o pedido é uma ação da web."
        ],
        correta: :c,
        explicacao:
          "O pedido usa dados do catálogo e do carrinho, mas tem dados próprios (o preço cobrado, a quantidade comprada) e regras próprias (não se completa um pedido sem estoque). Por isso ganha um contexto, `Orders`. O controller só traduz a requisição em uma chamada ao contexto, como em todo o módulo."
      },
      escopo_padrao: %{
        texto:
          "o comando `mix phx.gen.context Orders Order orders total_price:decimal` não fala em usuário. Mesmo assim, a migration gerada tem `user_id`, e todas as funções do contexto recebem um `scope`. Como o gerador soube?",
        opcoes: [
          a: "Ele procura uma tabela `users` no banco.",
          b:
            "O `phx.gen.auth` registrou em `config/config.exs` um escopo `user` com `default: true`, e os geradores usam o escopo padrão quando não recebem `--scope` nem `--no-scope`.",
          c: "Todo contexto gerado no Phoenix 1.8 tem `user_id`, com ou sem autenticação.",
          d: "O nome `Orders` está numa lista de recursos que pertencem a usuários."
        ],
        correta: :b,
        explicacao:
          "A configuração `config :hello, :scopes` diz qual módulo representa o escopo (`Hello.Accounts.Scope`), em qual assign do conn ele chega (`:current_scope`), qual campo vai no schema (`user_id`) e como chegar ao valor (`[:user, :id]`). Com `default: true`, ela vale para todo gerador. `--no-scope` desliga, como no `LineItem` logo abaixo, e `--scope NOME` escolhe outro escopo, quando a aplicação tem mais de um."
      },
      has_many_through: %{
        texto: "o `Order` vai declarar `has_many :products, through: [:line_items, :product]`. O que essa linha cria no banco?",
        opcoes: [
          a: "Uma tabela `order_products`, ligando pedidos e produtos.",
          b: "Uma coluna `product_ids` na tabela `orders`.",
          c: "Nada: é um caminho pelas associações que já existem, os `line_items` do pedido e o `product` de cada item.",
          d: "Um índice em `order_line_items.product_id`."
        ],
        correta: :c,
        explicacao:
          "Associações declaradas no schema são do Ecto, não do banco. `Repo.preload(order, :products)` percorre `order.line_items` e depois o `product` de cada item, usando as tabelas `order_line_items` e `products`, que já existem. Nenhuma migration é necessária."
      },
      nome_da_funcao: %{
        texto:
          "o contexto gerado já tem `Orders.create_order(scope, attrs)`. O guia mesmo assim escreve uma função nova, `Orders.complete_order(scope, cart)`. Por quê?",
        opcoes: [
          a: "Porque `create_order/2` não funciona com escopo.",
          b:
            "Porque completar um pedido é mais que inserir uma linha: cria os itens com o preço do momento, esvazia o carrinho e, um dia, cobra o pagamento. O nome diz a intenção.",
          c: "Porque um controller não pode chamar funções geradas.",
          d: "Por gosto; as duas fazem a mesma coisa."
        ],
        correta: :b,
        explicacao:
          "`create_order/2` só faz um insert a partir de parâmetros. Completar o pedido é uma operação de negócio com várias etapas, e é a mais importante do sistema: é onde o dinheiro muda de mãos. Um nome como `complete_order` deixa isso claro para quem lê o controller, sem precisar abrir o contexto."
      },
      transacao: %{
        texto:
          "dentro de `complete_order/2`, o pedido é inserido e, em seguida, `prune_cart_items/2` esvazia o carrinho. Se a segunda etapa devolver um erro, o que fica no banco?",
        opcoes: [
          a: "O pedido, e o carrinho continua cheio: o usuário pode comprar tudo de novo.",
          b: "Nada muda: as duas etapas estão no mesmo `Repo.transact/2`, e o erro desfaz o insert do pedido.",
          c: "O pedido, com `total_price` zerado.",
          d: "O Ecto tenta a segunda etapa de novo até dar certo."
        ],
        correta: :b,
        explicacao:
          "`Repo.transact/2` abre uma transação no banco e executa a função. Se ela devolve `{:ok, valor}`, a transação é confirmada (commit). Se devolve `{:error, motivo}`, tudo o que foi feito dentro dela é desfeito (rollback), e `transact` devolve o mesmo `{:error, motivo}`."
      },
      preco_no_pedido: %{
        texto: "o pedido da Bia foi fechado com o livro a $15. Depois, a loja sobe o preço do livro para $20. O que a página do pedido mostra para esse item?",
        opcoes: [
          a: "$20, porque o item do pedido aponta para o produto.",
          b: "$15: o preço foi copiado para `order_line_items.price` quando o pedido foi completado.",
          c: "Os dois preços, o antigo riscado.",
          d: "Um erro, porque o preço mudou."
        ],
        correta: :b,
        explicacao:
          "O item do pedido aponta para o produto (`product_id`), mas também guarda uma cópia do preço no momento da compra. A página usa a cópia, `item.price`. É isso que o guia chama de informação do momento (*point-in-time*): o que foi cobrado não pode mudar quando o catálogo muda."
      },
      pedido_alheio: %{
        texto:
          "o Caio está logado e abre o endereço do último pedido da Bia. A action `show` chama `Orders.get_order!(conn.assigns.current_scope, id)`. O que acontece?",
        opcoes: [
          a: "Ele vê o pedido, porque está logado.",
          b: "Ele é redirecionado para a página de login.",
          c:
            "`get_order!/2` procura um pedido com aquele `id` e com o `user_id` do Caio; não acha e levanta `Ecto.NoResultsError`, que vira uma resposta 404.",
          d: "Ele vê a página do pedido vazia."
        ],
        correta: :c,
        explicacao:
          "O escopo entra na própria consulta: `Repo.get_by!(Order, id: id, user_id: scope.user.id)`. Para o Caio, o pedido da Bia simplesmente não existe. O `phoenix_ecto` diz ao Plug que `Ecto.NoResultsError` corresponde ao status 404, então em produção ele vê a página \"Not Found\"."
      },
      carrinho_vazio: %{
        texto:
          "`complete_order/2` acabou de esvaziar o carrinho da Bia, mas o botão \"Complete order\" continua na página do carrinho. O que acontece se ela clicar nele?",
        opcoes: [
          a: "Nada: o navegador não envia o formulário.",
          b: "O Phoenix responde com o status 422.",
          c: "Um pedido de $0.00, sem itens, é criado: nada no código do guia impede isso.",
          d: "O Ecto levanta um erro porque a lista de itens está vazia."
        ],
        correta: :c,
        explicacao:
          "Com o carrinho vazio, a lista de itens é `[]` e o total é `0`. O insert é válido: o banco só exige que `total_price` não seja `NULL`. Uma regra como \"não existe pedido sem itens\" é regra de negócio, e quem precisa conhecê-la é o contexto `Orders`."
      },
      faq_estrutura: %{
        texto:
          "um colega quer dividir o `Catalog` em módulos menores: `ProductQueries` com as consultas e `ProductChangesets` com os changesets. Outra colega prefere tudo em `catalog.ex`. Segundo o FAQ, o que importa nessa decisão?",
        opcoes: [
          a: "O Phoenix exige um módulo de consultas por schema.",
          b:
            "Pouco: o que importa é a API pública do contexto e como os contextos conversam; os módulos internos são detalhes e podem variar de contexto para contexto.",
          c: "Todos os contextos da aplicação precisam seguir exatamente a mesma organização.",
          d: "Módulos separados deixam as consultas mais rápidas."
        ],
        correta: :b,
        explicacao:
          "Quem usa o catálogo chama `Catalog.list_products/0` e não sabe se a consulta mora em `catalog.ex` ou em `ProductQueries`. Como a superfície da aplicação é a API do contexto, a organização interna pode mudar sem afetar ninguém. O FAQ compara os contextos a casas de um bairro: o importante é que estejam bem cuidadas e bem ligadas; por dentro, cada uma pode ser um pouco diferente."
      }
    }
  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
    %{
      migracoes: ~S'''
      defmodule Hello.Repo.Migrations.CreateOrders do
        use Ecto.Migration

        def change do
          create table(:orders) do
            add :total_price, :decimal, precision: 15, scale: 6, null: false
            add :user_id, references(:users, on_delete: :delete_all)

            timestamps(type: :utc_datetime)
          end

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

      Trilha.migracao(20250209214612, Hello.Repo.Migrations.CreateOrders)

      # Na outra célula:
      defmodule Hello.Repo.Migrations.CreateOrderLineItems do
        use Ecto.Migration

        def change do
          create table(:order_line_items) do
            add :price, :decimal, precision: 15, scale: 6, null: false
            add :quantity, :integer
            add :order_id, references(:orders, on_delete: :nothing)
            add :product_id, references(:products, on_delete: :nothing)

            timestamps(type: :utc_datetime)
          end

          create index(:order_line_items, [:order_id])
          create index(:order_line_items, [:product_id])
        end
      end

      Trilha.migracao(20250209215050, Hello.Repo.Migrations.CreateOrderLineItems)
      ''',
      associacoes: ~S'''
      defmodule Hello.Orders.Order do
        use Ecto.Schema
        import Ecto.Changeset

        schema "orders" do
          field :total_price, :decimal

          belongs_to :user, Hello.Accounts.User
          has_many :line_items, Hello.Orders.LineItem
          has_many :products, through: [:line_items, :product]

          timestamps(type: :utc_datetime)
        end

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

      defmodule Hello.Orders.LineItem do
        use Ecto.Schema
        import Ecto.Changeset

        schema "order_line_items" do
          field :price, :decimal
          field :quantity, :integer

          belongs_to :order, Hello.Orders.Order
          belongs_to :product, Hello.Catalog.Product

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(line_item, attrs) do
          line_item
          |> cast(attrs, [:price, :quantity])
          |> validate_required([:price, :quantity])
        end
      end
      ''',
      complete_order: ~S'''
      # Na célula do Hello.ShoppingCart (seção "A aplicação Hello"), uma função nova:

        def prune_cart_items(%Scope{} = scope, %Cart{} = cart) do
          {_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
          {:ok, get_cart(scope)}
        end

      # Na célula do Hello.Orders, get_order!/2 com o preload:

        def get_order!(%Scope{} = scope, id) do
          Order
          |> Repo.get_by!(id: id, user_id: scope.user.id)
          |> Repo.preload(line_items: [:product])
        end

      # E, logo depois da linha `alias Hello.Orders.LineItem`:

        alias Hello.ShoppingCart

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

          line_items =
            Enum.map(cart.items, fn item ->
              %{
                product_id: item.product_id,
                price: item.product.price,
                quantity: item.quantity
              }
            end)

          order_changeset =
            Ecto.Changeset.change(%Order{}, %{
              user_id: scope.user.id,
              total_price: ShoppingCart.total_cart_price(cart),
              line_items: line_items
            })

          Repo.transact(fn ->
            with {:ok, order} <- Repo.insert(order_changeset),
                 {:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
              {:ok, order}
            end
          end)
          |> case do
            {:ok, order} ->
              broadcast_order(scope, {:created, order})
              {:ok, order}

            {:error, reason} ->
              {:error, reason}
          end
        end
      ''',
      pagina_do_pedido: ~S'''
      # Na célula do OrderController, a action show:

        def show(conn, %{"id" => id}) do
          order = Orders.get_order!(conn.assigns.current_scope, id)
          render(conn, :show, order: order)
        end

      # Na célula de lib/hello_web/controllers/order_html.ex:

      defmodule HelloWeb.OrderHTML do
        use HelloWeb, :html

        # order_html/show.html.heex
        def show(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>
              Thank you for your order!
              <:subtitle>
                <strong>Email: </strong>{@current_scope.user.email}
              </:subtitle>
            </.header>

            <.table id="items" rows={@order.line_items}>
              <:col :let={item} label="Title">{item.product.title}</:col>
              <:col :let={item} label="Quantity">{item.quantity}</:col>
              <:col :let={item} label="Price">
                {HelloWeb.CartHTML.currency_to_str(item.price)}
              </:col>
            </.table>

            <strong>Total price:</strong>
            {HelloWeb.CartHTML.currency_to_str(@order.total_price)}

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

      # Na cópia do CartHTML, depois da célula do OrderHTML, dentro do <.header>:

            <.header>
              My Cart
              <:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
              <:actions>
                <.button href={~p"/orders"} method="post">
                  Complete order
                </.button>
              </:actions>
            </.header>
      ''',
      carrinho_vazio: ~S'''
      # Na célula do Hello.Orders, uma cláusula antes da que já existe:

        def complete_order(%Scope{}, %ShoppingCart.Cart{items: []}), do: {:error, :carrinho_vazio}

        def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
          # ... como antes
        end

      # O OrderController não muda: qualquer {:error, _} já volta para /cart
      # com a flash "There was an error processing your order".
      ''',
      brecho: ~S'''
      # priv/repo/migrations/20250323000000_create_compras.exs
      defmodule Hello.Repo.Migrations.CreateCompras do
        use Ecto.Migration

        def change do
          create table(:compras) do
            add :user_id, references(:users, on_delete: :delete_all), null: false
            add :total_centavos, :integer, null: false

            timestamps(type: :utc_datetime)
          end

          create index(:compras, [:user_id])

          create table(:compra_itens) do
            add :compra_id, references(:compras, on_delete: :delete_all), null: false
            add :produto_id, references(:produtos, on_delete: :nothing), null: false
            add :preco_centavos, :integer, null: false

            timestamps(type: :utc_datetime)
          end

          create index(:compra_itens, [:compra_id])
          create unique_index(:compra_itens, [:produto_id])
        end
      end

      Trilha.migracao(20250323000000, Hello.Repo.Migrations.CreateCompras)

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

      # lib/hello/compras/compra.ex, lib/hello/compras/item.ex e lib/hello/compras.ex
      defmodule Hello.Compras.Compra do
        use Ecto.Schema

        schema "compras" do
          field :total_centavos, :integer

          belongs_to :user, Hello.Accounts.User
          has_many :itens, Hello.Compras.Item

          timestamps(type: :utc_datetime)
        end
      end

      defmodule Hello.Compras.Item do
        use Ecto.Schema

        schema "compra_itens" do
          field :preco_centavos, :integer

          belongs_to :compra, Hello.Compras.Compra
          belongs_to :produto, Hello.Vitrine.Produto

          timestamps(type: :utc_datetime)
        end
      end

      defmodule Hello.Compras do
        @moduledoc """
        As compras do Brechó: cada compra leva uma ou mais peças de uma vez.
        """

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

        alias Hello.Accounts.Scope
        alias Hello.Compras.Compra
        alias Hello.Vitrine.Produto

        def comprar(%Scope{} = scope, produto_ids) do
          ids = Enum.uniq(produto_ids)

          Repo.transact(fn ->
            disponiveis = from p in Produto, where: p.id in ^ids and not p.vendido, select: p

            case Repo.update_all(disponiveis, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
              {quantidade, produtos} when quantidade == length(ids) ->
                itens = Enum.map(produtos, &%{produto_id: &1.id, preco_centavos: &1.preco_centavos})

                %Compra{}
                |> Ecto.Changeset.change(
                  user_id: scope.user.id,
                  total_centavos: Enum.sum_by(produtos, & &1.preco_centavos),
                  itens: itens
                )
                |> Repo.insert()

              _ ->
                {:error, :indisponivel}
            end
          end)
        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
    # As conferências fazem dezenas de consultas; o log delas esconderia o resultado.
    nivel = Logger.level()
    Logger.configure(level: :none)

    resultado =
      try do
        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)
      after
        Logger.configure(level: nivel)
      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
    %{
      migracoes: [
        {"As duas migrations estão registradas com as versões do guia.",
         fn ->
           esperadas = [
             {20250209214612, Hello.Repo.Migrations.CreateOrders},
             {20250209215050, Hello.Repo.Migrations.CreateOrderLineItems}
           ]

           case esperadas -- migracoes() do
             [] -> :ok
             faltando -> {:erro, "Avalie as células das duas migrations; cada uma termina com `Trilha.migracao/2`. Faltam: `#{inspect(faltando)}`."}
           end
         end},
        {"As tabelas `orders` e `order_line_items` existem.",
         fn ->
           with :ok <- colunas("orders", ~w(id total_price user_id inserted_at updated_at)) do
             colunas("order_line_items", ~w(id price quantity order_id product_id inserted_at updated_at))
           end
         end},
        {"`orders.total_price` é `numeric(15,6)` e não aceita `NULL`.", fn -> coluna_de_dinheiro("orders", "total_price") end},
        {"`order_line_items.price` é `numeric(15,6)` e não aceita `NULL`.", fn -> coluna_de_dinheiro("order_line_items", "price") end}
      ],
      associacoes: [
        {"`Order` tem `belongs_to :user`, ligado a `Hello.Accounts.User`.",
         fn -> associacao(Hello.Orders.Order, :user, Ecto.Association.BelongsTo, Hello.Accounts.User) end},
        {"`Order` tem `has_many :line_items`, ligado a `Hello.Orders.LineItem`.",
         fn -> associacao(Hello.Orders.Order, :line_items, Ecto.Association.Has, Hello.Orders.LineItem) end},
        {"`Order` tem `has_many :products, through: [:line_items, :product]`.",
         fn ->
           case apply(Hello.Orders.Order, :__schema__, [:association, :products]) do
             %Ecto.Association.HasThrough{through: [:line_items, :product]} -> :ok
             nil -> {:erro, "Falta a associação `:products` no `Order`."}
             outra -> {:erro, "`:products` existe, mas como `#{inspect(outra.__struct__)}`. Use `has_many :products, through: [:line_items, :product]`."}
           end
         end},
        {"`LineItem` tem `belongs_to :order` e `belongs_to :product`, ligado a `Hello.Catalog.Product`.",
         fn ->
           with :ok <- associacao(Hello.Orders.LineItem, :order, Ecto.Association.BelongsTo, Hello.Orders.Order) do
             associacao(Hello.Orders.LineItem, :product, Ecto.Association.BelongsTo, Hello.Catalog.Product)
           end
         end},
        {"`Repo.preload(order, :products)` traz os produtos pelos itens do pedido.",
         fn ->
           [{p1, _, _}, {p2, _, _}] = produtos_do_catalogo()

           resultado =
             apply(@repo, :transaction, [
               fn ->
                 itens = [
                   struct(Hello.Orders.LineItem, price: Decimal.new("1"), quantity: 1, product_id: p1),
                   struct(Hello.Orders.LineItem, price: Decimal.new("1"), quantity: 1, product_id: p2)
                 ]

                 pedido = apply(@repo, :insert!, [struct(Hello.Orders.Order, total_price: Decimal.new("2"), line_items: itens)])
                 produtos = apply(@repo, :preload, [pedido, :products]).products
                 apply(@repo, :rollback, [produtos |> Enum.map(& &1.id) |> Enum.sort()])
               end
             ])

           case resultado do
             {:error, [^p1, ^p2]} -> :ok
             {:error, outros} -> {:erro, "O preload trouxe os produtos `#{inspect(outros)}`; o esperado era `#{inspect([p1, p2])}`."}
           end
         end}
      ],
      complete_order: [
        {"`ShoppingCart.prune_cart_items/2` e `Orders.complete_order/2` existem.",
         fn ->
           with :ok <- exporta(Hello.ShoppingCart, prune_cart_items: 2) do
             exporta(Hello.Orders, complete_order: 2)
           end
         end},
        {"`complete_order/2` grava o pedido com o total do carrinho e um item por produto, com o preço e a quantidade, e esvazia o carrinho.",
         fn ->
           conferindo(fn ->
             [{p1, _, preco1}, {p2, _, preco2}] = produtos_do_catalogo()
             {scope, cart} = carrinho_de_conferencia("conferencia-pedido@example.com", [{p1, 2}, {p2, 1}])
             total_esperado = preco1 |> Decimal.mult(2) |> Decimal.add(preco2)

             case apply(Hello.Orders, :complete_order, [scope, cart]) do
               {:ok, %{id: id}} ->
                 [[total, dono]] = sql("SELECT total_price, user_id FROM orders WHERE id = $1", [id]).rows
                 itens = sql("SELECT product_id, price, quantity FROM order_line_items WHERE order_id = $1 ORDER BY product_id", [id]).rows
                 restantes = apply(Hello.ShoppingCart, :get_cart, [scope]).items

                 cond do
                   dono != scope.user.id ->
                     {:erro, "O pedido foi gravado com `user_id` #{inspect(dono)}, e não com o do usuário do escopo."}

                   not Decimal.equal?(total, total_esperado) ->
                     {:erro, "O total gravado foi #{total}; o esperado era #{total_esperado} (2 × #{preco1} + #{preco2}). Use `ShoppingCart.total_cart_price(cart)`."}

                   length(itens) != 2 ->
                     {:erro, "O pedido tem #{length(itens)} itens; o carrinho tinha 2 produtos. Passe a lista `line_items` no `Ecto.Changeset.change/2`."}

                   not match?([[^p1, _, 2], [^p2, _, 1]], itens) or
                       not Enum.all?(Enum.zip(itens, [preco1, preco2]), fn {[_, preco, _], esperado} -> Decimal.equal?(preco, esperado) end) ->
                     {:erro, "Os itens gravados foram `#{inspect(itens)}`; cada item precisa do `product_id`, do preço do produto e da quantidade do carrinho."}

                   restantes != [] ->
                     {:erro, "O pedido foi gravado, mas o carrinho ainda tem #{length(restantes)} itens. Chame `ShoppingCart.prune_cart_items/2` dentro da transação."}

                   true ->
                     :ok
                 end

               outro ->
                 {:erro, "`complete_order/2` devolveu `#{inspect(outro, limit: 6)}`; o esperado é `{:ok, %Order{}}`."}
             end
           end)
         end},
        {"`Orders.get_order!/2` devolve o pedido com os itens e os produtos carregados.",
         fn ->
           conferindo(fn ->
             [{p1, titulo, _} | _] = produtos_do_catalogo()
             {scope, cart} = carrinho_de_conferencia("conferencia-preload@example.com", [{p1, 1}])
             {:ok, %{id: id}} = apply(Hello.Orders, :complete_order, [scope, cart])

             case apply(Hello.Orders, :get_order!, [scope, id]).line_items do
               [%{product: %{title: ^titulo}}] -> :ok
               %Ecto.Association.NotLoaded{} -> {:erro, "Os itens não vieram carregados. Acrescente `|> Repo.preload(line_items: [:product])` em `get_order!/2`."}
               [%{product: %Ecto.Association.NotLoaded{}}] -> {:erro, "Os itens vieram, mas sem o produto. O preload é `line_items: [:product]`."}
               outro -> {:erro, "Os itens vieram como `#{inspect(outro, limit: 4)}`."}
             end
           end)
         end}
      ],
      pagina_do_pedido: [
        {"Com itens no carrinho, `GET /cart` mostra o botão \"Complete order\", que envia `POST /orders`.",
         fn ->
           conferindo(fn ->
             [{p1, _, _} | _] = produtos_do_catalogo()
             {scope, _cart} = carrinho_de_conferencia("conferencia-web@example.com", [{p1, 1}])

             case requisicao(:get, "/cart", nil, logado(scope)) do
               {:ok, %{status: 200, resp_body: corpo}} ->
                 cond do
                   not (corpo =~ "Complete order") ->
                     {:erro, "A página do carrinho não tem o botão. Ele vai no slot `:actions` do `<.header>`, na cópia do `CartHTML` que fica logo depois da célula do `OrderHTML`."}

                   not (corpo =~ ~s(data-method="post")) or not (corpo =~ ~s(href="/orders")) ->
                     {:erro, "O botão existe, mas não envia `POST /orders`. Use `<.button href={~p\"/orders\"} method=\"post\">`."}

                   true ->
                     :ok
                 end

               {:ok, conn} ->
                 {:erro, "`GET /cart` respondeu com o status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"`POST /orders` completa o pedido e redireciona para `/orders/ID`, que mostra o pedido ao dono.",
         fn ->
           conferindo(fn ->
             [{p1, titulo, _} | _] = produtos_do_catalogo()
             {scope, _cart} = carrinho_de_conferencia("conferencia-web@example.com", [{p1, 1}])

             with {:ok, %{status: 302} = resp} <- requisicao(:post, "/orders", nil, logado(scope)),
                  ["/orders/" <> id] <- Plug.Conn.get_resp_header(resp, "location"),
                  {:ok, %{status: 200, resp_body: corpo}} <- requisicao(:get, "/orders/" <> id, nil, logado(scope)) do
               cond do
                 not (corpo =~ "Thank you for your order!") -> {:erro, "A página do pedido não tem o título \"Thank you for your order!\"."}
                 not (corpo =~ titulo) -> {:erro, "A página do pedido não mostra o título do produto, `#{titulo}`."}
                 not (corpo =~ "conferencia-web@example.com") -> {:erro, "A página do pedido não mostra o e-mail do usuário (`@current_scope.user.email`)."}
                 true -> :ok
               end
             else
               {:ok, %{status: status} = resp} ->
                 {:erro, "Veio o status `#{status}`, com `location` #{inspect(Plug.Conn.get_resp_header(resp, "location"))}."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}

               outro ->
                 {:erro, "O redirect foi para `#{inspect(outro)}`; o esperado é `/orders/ID`."}
             end
           end)
         end},
        {"Outro usuário que abre o mesmo endereço recebe `Ecto.NoResultsError`, que vira 404.",
         fn ->
           conferindo(fn ->
             [{p1, _, _} | _] = produtos_do_catalogo()
             {dono, cart} = carrinho_de_conferencia("conferencia-dono@example.com", [{p1, 1}])
             {:ok, %{id: id}} = apply(Hello.Orders, :complete_order, [dono, cart])
             intruso = usuario_de_conferencia("conferencia-intruso@example.com")

             case requisicao(:get, "/orders/#{id}", nil, logado(intruso)) do
               {:erro, %Ecto.NoResultsError{}} -> :ok
               {:ok, conn} -> {:erro, "Outro usuário recebeu o status `#{conn.status}`. Use `Orders.get_order!(conn.assigns.current_scope, id)` na action `show`."}
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end}
      ],
      carrinho_vazio: [
        {"`complete_order/2` com o carrinho vazio devolve `{:error, :carrinho_vazio}` e não grava pedido.",
         fn ->
           conferindo(fn ->
             {scope, cart} = carrinho_de_conferencia("conferencia-vazio@example.com", [])
             resultado = apply(Hello.Orders, :complete_order, [scope, cart])
             pedidos = coluna("SELECT count(*) FROM orders WHERE user_id = $1", [scope.user.id])

             cond do
               resultado != {:error, :carrinho_vazio} ->
                 {:erro, "Com o carrinho vazio, `complete_order/2` devolveu `#{inspect(resultado, limit: 5)}`."}

               pedidos != [0] ->
                 {:erro, "A função devolveu o erro, mas um pedido foi gravado. Confira o carrinho antes de inserir."}

               true ->
                 :ok
             end
           end)
         end},
        {"`POST /orders` com o carrinho vazio volta para `/cart` com a flash de erro.",
         fn ->
           conferindo(fn ->
             {scope, _cart} = carrinho_de_conferencia("conferencia-vazio@example.com", [])

             case requisicao(:post, "/orders", nil, logado(scope)) do
               {:ok, %{status: 302} = conn} ->
                 cond do
                   Plug.Conn.get_resp_header(conn, "location") != ["/cart"] ->
                     {:erro, "O redirect foi para `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`; o esperado é `/cart`."}

                   Phoenix.Flash.get(conn.assigns.flash, :error) == nil ->
                     {:erro, "O redirect está certo, mas não há flash de erro."}

                   true ->
                     :ok
                 end

               {:ok, conn} ->
                 {:erro, "A resposta veio com o status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"Um carrinho com itens continua virando pedido.",
         fn ->
           conferindo(fn ->
             [{p1, _, _} | _] = produtos_do_catalogo()
             {scope, cart} = carrinho_de_conferencia("conferencia-cheio@example.com", [{p1, 1}])

             case apply(Hello.Orders, :complete_order, [scope, cart]) do
               {:ok, _} -> :ok
               outro -> {:erro, "Com um item no carrinho, `complete_order/2` devolveu `#{inspect(outro, limit: 5)}`."}
             end
           end)
         end}
      ],
      brecho: [
        {"A tabela `compras` existe, com `user_id`, `total_centavos` e os timestamps.",
         fn -> colunas("compras", ~w(id user_id total_centavos inserted_at updated_at)) end},
        {"`Hello.Compras.comprar/2` existe.", fn -> exporta(Hello.Compras, comprar: 2) end},
        {"Uma compra de duas peças devolve `{:ok, compra}`, marca as duas como vendidas e grava o total para o usuário.",
         fn ->
           conferindo(fn ->
             scope = usuario_de_conferencia("conferencia-compra@example.com")
             saia = peca("Conferência Saia", 3000)
             blusa = peca("Conferência Blusa", 4500)

             case apply(Hello.Compras, :comprar, [scope, [saia, blusa]]) do
               {:ok, %{id: id}} ->
                 cond do
                   coluna("SELECT count(*) FROM produtos WHERE id = ANY($1) AND vendido", [[saia, blusa]]) != [2] ->
                     {:erro, "A compra deu certo, mas as peças não ficaram com `vendido` verdadeiro."}

                   sql("SELECT total_centavos, user_id FROM compras WHERE id = $1", [id]).rows != [[7500, scope.user.id]] ->
                     {:erro, "A compra gravada foi `#{inspect(sql("SELECT total_centavos, user_id FROM compras WHERE id = $1", [id]).rows)}`; o esperado é total 7500 para o usuário #{scope.user.id}."}

                   true ->
                     :ok
                 end

               outro ->
                 {:erro, "`comprar/2` devolveu `#{inspect(outro, limit: 6)}`; o esperado é `{:ok, compra}`."}
             end
           end)
         end},
        {"Uma compra com uma peça já vendida devolve `{:error, :indisponivel}` e não muda nada.",
         fn ->
           conferindo(fn ->
             scope = usuario_de_conferencia("conferencia-azar@example.com")
             casaco = peca("Conferência Casaco", 9000)
             vendida = peca("Conferência Vendida", 2000, true)
             resultado = apply(Hello.Compras, :comprar, [scope, [casaco, vendida]])

             cond do
               resultado != {:error, :indisponivel} ->
                 {:erro, "Com uma peça já vendida, `comprar/2` devolveu `#{inspect(resultado, limit: 6)}`."}

               coluna("SELECT vendido FROM produtos WHERE id = $1", [casaco]) != [false] ->
                 {:erro, "A compra falhou, mas o casaco ficou marcado como vendido. A marcação precisa ser desfeita: faça tudo dentro de `Repo.transact/2` e devolva `{:error, :indisponivel}` de dentro dela."}

               coluna("SELECT count(*) FROM compras WHERE user_id = $1", [scope.user.id]) != [0] ->
                 {:erro, "A compra falhou, mas uma linha foi gravada em `compras`."}

               true ->
                 :ok
             end
           end)
         end},
        {"Seis pessoas tentam comprar a mesma peça ao mesmo tempo: só uma consegue.",
         fn ->
           conferindo(fn ->
             disputada = peca("Conferência Disputada", 5000)
             escopos = for i <- 1..6, do: usuario_de_conferencia("conferencia-corrida-#{i}@example.com")

             resultados =
               escopos
               |> Task.async_stream(fn scope -> apply(Hello.Compras, :comprar, [scope, [disputada]]) end, max_concurrency: 6, timeout: 15_000)
               |> Enum.map(fn {:ok, resultado} -> resultado end)

             certas = Enum.count(resultados, &match?({:ok, _}, &1))
             gravadas = coluna("SELECT count(*) FROM compras WHERE user_id = ANY($1)", [Enum.map(escopos, & &1.user.id)])

             cond do
               certas != 1 -> {:erro, "#{certas} compras deram certo. A verificação de `vendido` e a marcação precisam estar no mesmo `update_all`, com `where: not p.vendido`."}
               gravadas != [1] -> {:erro, "Uma compra deu certo, mas há #{hd(gravadas)} linhas em `compras` para esses usuários."}
               true -> :ok
             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 coluna_de_dinheiro(tabela, campo) do
    consulta = """
    SELECT format_type(atttypid, atttypmod), attnotnull FROM pg_attribute
    WHERE attrelid = to_regclass($1) AND attname = $2 AND NOT attisdropped
    """

    case sql(consulta, [tabela, campo]).rows do
      [["numeric(15,6)", true]] ->
        :ok

      [[tipo, nao_nulo]] ->
        {:erro,
         "A coluna está como `#{tipo}`#{if nao_nulo, do: " not null", else: ", aceitando NULL"}. Se o migrate rodou antes da mudança, desfaça as duas migrations com " <>
           "`Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, to: 20250209214612)`, avalie de novo as células das migrations e rode o migrate."}

      [] ->
        {:erro, "A coluna `#{campo}` não existe em `#{tabela}`."}
    end
  end

  defp associacao(schema, nome, tipo, relacionado) do
    case apply(schema, :__schema__, [:association, nome]) do
      %{__struct__: ^tipo, related: ^relacionado} = assoc ->
        if tipo == Ecto.Association.Has and assoc.cardinality != :many,
          do: {:erro, "`#{inspect(nome)}` é `has_one`; o pedido tem vários itens, então é `has_many`."},
          else: :ok

      nil ->
        {:erro, "`#{inspect(schema)}` não tem a associação `#{inspect(nome)}`. Troque o `field` pela associação e avalie a célula."}

      outra ->
        {:erro, "`#{inspect(nome)}` está declarada como `#{inspect(outra.__struct__)}` para `#{inspect(outra.related)}`."}
    end
  end

  # Os dois primeiros produtos do catálogo, como {id, título, preço}.
  defp produtos_do_catalogo do
    case sql("SELECT id, title, price FROM products ORDER BY id LIMIT 2").rows do
      [[i1, t1, p1], [i2, t2, p2]] -> [{i1, t1, p1}, {i2, t2, p2}]
      _ -> raise "O catálogo precisa de dois produtos. Avalie a célula dos seeds, na seção \"Antes de começar\"."
    end
  end

  defp usuario_de_conferencia(email) do
    {:ok, user} = apply(Hello.Accounts, :register_user, [%{email: email}])
    apply(Hello.Accounts.Scope, :for_user, [user])
  end

  # Um usuário novo com um carrinho; `itens` é uma lista de {product_id, quantidade}.
  defp carrinho_de_conferencia(email, itens) do
    scope = usuario_de_conferencia(email)
    {:ok, cart} = apply(Hello.ShoppingCart, :create_cart, [scope, %{}])

    for {produto_id, quantidade} <- itens, _ <- 1..quantidade//1 do
      {:ok, _} = apply(Hello.ShoppingCart, :add_item_to_cart, [scope, cart, produto_id])
    end

    {scope, apply(Hello.ShoppingCart, :get_cart, [scope])}
  end

  # Um conn com o usuário do escopo na sessão, como depois do login.
  defp logado(scope) do
    Phoenix.ConnTest.build_conn() |> Plug.Test.init_test_session(%{user_id: scope.user.id})
  end

  defp peca(nome, preco, vendido \\ false) do
    [[id]] =
      sql(
        "INSERT INTO produtos (nome, preco_centavos, vendido, inserted_at, updated_at) VALUES ($1, $2, $3, now(), now()) RETURNING id",
        [nome, preco, vendido]
      ).rows

    id
  end

  # Roda o teste e, aconteça o que acontecer, apaga os usuários e as peças criados por ele.
  defp conferindo(teste) do
    teste.()
  after
    limpar_conferencia()
  end

  defp limpar_conferencia do
    usuarios = coluna("SELECT id FROM users WHERE email LIKE 'conferencia%'")

    if usuarios != [] do
      if tabela?("order_line_items") do
        sql("DELETE FROM order_line_items WHERE order_id IN (SELECT id FROM orders WHERE user_id = ANY($1))", [usuarios])
      end

      if tabela?("orders"), do: sql("DELETE FROM orders WHERE user_id = ANY($1)", [usuarios])

      if tabela?("compras") do
        compras = coluna("SELECT id FROM compras WHERE user_id = ANY($1)", [usuarios])
        for [tabela, campo] <- referencias("compras"), do: sql("DELETE FROM #{tabela} WHERE #{campo} = ANY($1)", [compras])
        sql("DELETE FROM compras WHERE id = ANY($1)", [compras])
      end

      # Os carrinhos e os itens deles somem junto, pelo on_delete: :delete_all.
      sql("DELETE FROM users WHERE id = ANY($1)", [usuarios])
    end

    if tabela?("produtos") do
      pecas = coluna("SELECT id FROM produtos WHERE nome LIKE 'Conferência%'")
      for [tabela, campo] <- referencias("produtos"), do: sql("DELETE FROM #{tabela} WHERE #{campo} = ANY($1)", [pecas])
      sql("DELETE FROM produtos WHERE id = ANY($1)", [pecas])
    end
  end

  defp tabela?(nome), do: coluna("SELECT to_regclass($1)::text", [nome]) == [nome]

  # As tabelas (e colunas) com chave estrangeira apontando para `tabela`.
  defp referencias(tabela) do
    sql(
      """
      SELECT c.conrelid::regclass::text, a.attname
      FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
      WHERE c.contype = 'f' AND c.confrelid = to_regclass($1)
      """,
      [tabela]
    ).rows
  end

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Avalie a célula do router com a rota nova."
  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: :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(%ArgumentError{message: mensagem}) do
    "`ArgumentError`: #{mensagem}"
  end

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

Como usar este notebook

Este notebook acompanha os dois últimos guias de modelagem de dados da documentação do Phoenix 1.8: Bringing It Home, em que o carrinho vira pedido, e o FAQ sobre contextos. Ele fecha o módulo 6 da trilha.

A aplicação começa onde o notebook 10 terminou: o contexto Catalog, com produtos e categorias; a autenticação; e o contexto ShoppingCart, com o carrinho de cada usuário. Ao longo do notebook você cria o quarto contexto, Orders, que transforma o carrinho em pedido.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, os exercícios "Sua vez" têm uma conferência automática e uma solução escondida, e os erros fazem parte do conteúdo. Trilha.navegar/4 faz requisições como um navegador: guarda os cookies de cada visitante (aqui, a Bia e o Caio), segue os redirects e mostra a flash e o texto da página. Você também pode abrir http://localhost:4010 no navegador e entrar com qualquer e-mail.

A aplicação fica em http://localhost:4010. 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

Estas células trazem a aplicação no estado em que o guia "Cross-context Boundaries" termina. O código dos contextos e dos controllers já apareceu nos notebooks 08 a 10; aqui ele vem sem os @doc gerados, para encurtar.

Duas diferenças em relação a um projeto de verdade, as mesmas do notebook 10:

  • Autenticação reduzida. O mix phx.gen.auth gera login por link enviado por e-mail, senha opcional (com a biblioteca bcrypt_elixir, que precisa de compilador C) e tokens de sessão guardados na tabela users_tokens. Aqui, os módulos e as funções que o resto da aplicação usa têm os mesmos nomes do gerado (Hello.Accounts, Hello.Accounts.Scope, HelloWeb.UserAuth, fetch_current_scope_for_user, require_authenticated_user), mas o login só pede o e-mail, cria a conta se ela não existir, e a sessão guarda o user_id. Não use isso fora de um notebook. O módulo de autenticação da trilha estuda o gerador completo.
  • O router aparece duas vezes. O router completo importa o HelloWeb.UserAuth, e o UserAuth usa ~p, que confere cada caminho no router durante a compilação. No projeto, o compilador resolve esse ciclo sozinho. No notebook, uma primeira versão do router, só com as rotas que o UserAuth usa, vem antes dele; a versão completa vem depois.

config/dev.exs: a configuração do banco, a mesma dos notebooks 07 a 10. 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.

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, primeira versão: só as duas rotas que o HelloWeb.UserAuth cita com ~p. Avaliar o router mostra avisos de que os controllers ainda não existem (init/1 is undefined). É esperado: eles vêm mais abaixo.

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

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/users/log-in", UserSessionController, :new
  end
end

lib/hello_web/endpoint.ex: como nos notebooks 08 a 10, o Plug.Static serve o phoenix_html.js direto do pacote, no lugar do app.js compilado de um projeto. É ele que faz os links com method="post" e method="delete" 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: o arquivo gerado pelo Phoenix 1.8.16, sem o Gettext (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

priv/repo/migrations/: as seis migrations dos notebooks 08 a 10, numa célula só. A dos usuários é a da autenticação reduzida: só a tabela users, com o e-mail.

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

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

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

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(20250201185747, Hello.Repo.Migrations.CreateProducts)
Trilha.migracao(20250203192325, Hello.Repo.Migrations.CreateCategories)
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)
Trilha.migracao(20250205200000, Hello.Repo.Migrations.CreateUsersAuthTables)
Trilha.migracao(20250205203128, Hello.Repo.Migrations.CreateCarts)
Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)

Trilha.migracoes()

lib/hello/accounts/user.ex, lib/hello/accounts.ex e lib/hello/accounts/scope.ex: a autenticação reduzida. O Scope é o arquivo gerado pelo phx.gen.auth, igual.

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/catalog/product.ex, lib/hello/catalog/category.ex e lib/hello/catalog.ex: o catálogo no fim do notebook 09.

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

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 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/shopping_cart/cart.ex, cart_item.ex e lib/hello/shopping_cart.ex: o carrinho no fim do notebook 10. Um dos exercícios deste notebook acrescenta uma função a esta célula.

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

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

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

  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

  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 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

  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

  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

  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

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

    Cart.changeset(cart, attrs, scope)
  end

  def list_cart_items do
    Repo.all(CartItem)
  end

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

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

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

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

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

lib/hello_web/user_auth.ex: os plugs de autenticação, com os nomes do gerado.

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

lib/hello_web/router.ex, versão completa: o estado do fim do notebook 10, com o plug fetch_current_cart e as rotas do carrinho e do login. As três rotas de /produtos são do Brechó, o mini-marketplace da trilha (notebook 08), usadas no desafio do fim. De novo, os avisos init/1 is undefined são esperados.

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

    # Brechó (notebook 08)
    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
  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/components/layouts.ex: o root layout tem o menu do usuário, que o phx.gen.auth injeta no root.html.heex. Como nos notebooks anteriores, o Tailwind e o daisyUI vêm de uma CDN.

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

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

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

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

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/controllers/product_controller.ex e product_html.ex: as páginas do catálogo, com o botão "Add to cart" do notebook 10.

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

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

  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

lib/hello_web/controllers/cart_item_controller.ex, cart_controller.ex e cart_html.ex: o carrinho.

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

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

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

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL dos notebooks 07 a 10, 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 quando os dados de um usuário mudam.

{: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 e os cookies dos visitantes, 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()

Rode as seis migrations (é o mix ecto.migrate):

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

priv/repo/seeds.exs: as categorias do notebook 09 e os dois produtos dos guias.

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.00",
    views: 0
  })

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

Hello.Catalog.list_products() |> Enum.map(&{&1.id, &1.title, &1.price})

A Bia e o carrinho dela

A Bia entra com o e-mail bia@example.com (a conta é criada no primeiro login) e põe no carrinho dois exemplares do livro e um baralho de flashcards. Trilha.visitar/4 faz cada requisição sem mostrar nada; a última célula mostra o carrinho como a Bia o vê.

Trilha.visitar("bia", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=2")

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

Dois livros a $15.00 dão $30.00, mais o baralho de $5.00: o total é $35.00. Os números entre colchetes são os valores dos campos de quantidade do formulário.

Onde mora o pedido

Na documentação: Bringing It Home

Com o Catalog e o ShoppingCart, a loja mostra produtos e guarda o carrinho de cada usuário. Falta o último passo: fechar a compra. O guia não vai até o pagamento nem a entrega, mas monta o começo, o pedido. Como nos guias anteriores, a primeira decisão é onde esse código vai morar.

Trilha.pergunta(:onde_mora)

O guia pesa as duas candidatas óbvias. O catálogo está fora: ele cuida da exibição dos produtos. O carrinho é mais tentador, porque o pedido nasce dele. Mas o pedido tem dados que o carrinho não tem e regras que o carrinho não segue:

  • dados próprios: o preço de cada item no momento do pagamento. O preço de um produto pode mudar amanhã; o que foi cobrado, não;
  • regras próprias: um item sem estoque pode ficar no carrinho, mas um pedido com esse item não pode ser completado.

Por isso o pedido ganha o seu próprio contexto, Orders.

Gerando o contexto Orders

Na documentação: Bringing It Home, a partir de "Naming wise, Orders clearly defines our context". O guia não tem subseções; os títulos deste notebook dividem o texto dele.

O guia gera o contexto com:

$ mix phx.gen.context Orders Order orders total_price:decimal

O comando não diz nada sobre usuários. Antes de ver o que ele gerou:

Trilha.pergunta(:escopo_padrao)

Esta é a configuração que o mix phx.gen.auth acrescenta ao config/config.exs, copiada de um projeto gerado com o Phoenix 1.8.16:

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
  ]

Cada chave responde a uma pergunta que o gerador faz: qual módulo é o escopo (module), em qual assign do conn ele chega (assign_key), como achar o id do dono a partir do escopo (access_path, ou seja, scope.user.id), qual coluna guarda esse id e de que tipo (schema_key, schema_type), para qual tabela ela aponta (schema_table) e como os testes gerados criam um usuário logado (as duas últimas). default: true faz o escopo user valer para todo gerador que não receba --scope nem --no-scope.

A saída do gerador:

* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20250209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

priv/repo/migrations/20250209214612_create_orders.exs, como o gerador a escreve. A versão é a do guia. O escopo trouxe a coluna user_id, que aponta para users e apaga os pedidos quando o usuário é apagado (on_delete: :delete_all), e um índice nela. O guia mostra a mesma linha com type: :id, que é o tipo padrão; o gerador do 1.8.16 não escreve essa opção.

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

  def change do
    create table(:orders) do
      add :total_price, :decimal
      add :user_id, references(:users, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

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

Trilha.migracao(20250209214612, Hello.Repo.Migrations.CreateOrders)

A tabela de pedidos sozinha guarda pouco: quem comprou e quanto pagou. O preço de cada produto no momento da compra fica em outra tabela, a dos itens do pedido. O guia gera o schema LineItem no mesmo contexto, desta vez com --no-scope: os itens pertencem a um pedido, e o pedido já pertence a um usuário.

$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products --no-scope

You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20250209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex

priv/repo/migrations/20250209215050_create_order_line_items.exs, como gerada. Os campos references viraram chaves estrangeiras com índice. Ao contrário dos itens do carrinho, aqui o guia mantém on_delete: :nothing: um pedido é um registro do que aconteceu e não deve sumir em cascata.

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

  def change do
    create table(:order_line_items) do
      add :price, :decimal
      add :quantity, :integer
      add :order_id, references(:orders, on_delete: :nothing)
      add :product_id, references(:products, on_delete: :nothing)

      timestamps(type: :utc_datetime)
    end

    create index(:order_line_items, [:order_id])
    create index(:order_line_items, [:product_id])
  end
end

Trilha.migracao(20250209215050, Hello.Repo.Migrations.CreateOrderLineItems)

Sua vez: as colunas de dinheiro

Antes de rodar o mix ecto.migrate, o guia ajusta as duas migrations da mesma forma que fez com products.price e cart_items.price_when_carted: o dinheiro vira :decimal com precision: 15 e scale: 6, sem perda de precisão nas contas, e com null: false, porque todo pedido e todo item precisam de um preço.

Edite as duas células acima: total_price em orders e price em order_line_items. Avalie as duas e só então a célula abaixo.

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

A coluna total_price deve aparecer como numeric(15,6) e not null. A chave estrangeira de user_id aparece como ON DELETE CASCADE, o nome que o PostgreSQL dá ao on_delete: :delete_all. Se o migrate rodou antes da edição, a tabela foi criada do jeito antigo e rodar de novo não muda nada: desfaça as duas migrations com Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, to: 20250209214612), avalie de novo as duas células e rode o migrate.

Trilha.conferir(:migracoes)
Trilha.solucao(:migracoes)

As associações

Na documentação: Bringing It Home, a partir de "With our migration in place, let's wire up our orders and line items associations".

lib/hello/orders/order.ex e lib/hello/orders/line_item.ex, como gerados. O escopo pôs field :user_id, :id no Order e um terceiro argumento no changeset/3: o changeset recebe o escopo e grava o user_id com put_change/3, sem aceitá-lo dos parâmetros. Assim, ninguém cria um pedido em nome de outra pessoa mandando um user_id no formulário.

defmodule Hello.Orders.Order do
  use Ecto.Schema
  import Ecto.Changeset

  schema "orders" do
    field :total_price, :decimal
    field :user_id, :id

    timestamps(type: :utc_datetime)
  end

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

defmodule Hello.Orders.LineItem do
  use Ecto.Schema
  import Ecto.Changeset

  schema "order_line_items" do
    field :price, :decimal
    field :quantity, :integer
    field :order_id, :id
    field :product_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(line_item, attrs) do
    line_item
    |> cast(attrs, [:price, :quantity])
    |> validate_required([:price, :quantity])
  end
end

Os campos *_id são só números. Para navegar de um pedido aos seus itens e aos produtos, o guia troca esses campos por associações. Uma delas é nova na trilha:

Trilha.pergunta(:has_many_through)

Sua vez: ligar pedido, itens, produtos e usuário

Edite a célula acima como o guia indica:

  schema "orders" do
    field :total_price, :decimal
-   field :user_id, :id

+   belongs_to :user, Hello.Accounts.User
+   has_many :line_items, Hello.Orders.LineItem
+   has_many :products, through: [:line_items, :product]

    timestamps(type: :utc_datetime)
  end
  schema "order_line_items" do
    field :price, :decimal
    field :quantity, :integer
-   field :order_id, :id
-   field :product_id, :id

+   belongs_to :order, Hello.Orders.Order
+   belongs_to :product, Hello.Catalog.Product

    timestamps(type: :utc_datetime)
  end

belongs_to :user continua criando o campo user_id, e o changeset/3 gerado segue funcionando. has_many :line_items é o lado oposto do belongs_to :order. has_many :products, through: [:line_items, :product] é o caminho de dois passos: do pedido aos itens, de cada item ao produto.

O belongs_to :product aponta para um schema de outro contexto, Hello.Catalog.Product. É a mesma escolha que o notebook 10 fez com os itens do carrinho: quando a dependência entre os dados é real, um join no banco é aceitável, e o contexto Orders não precisa chamar o Catalog para cada produto.

A conferência insere um pedido com dois itens dentro de uma transação, carrega :products pelo caminho through e desfaz tudo no fim.

Trilha.conferir(:associacoes)
Trilha.solucao(:associacoes)

O contexto Orders

Na documentação: Bringing It Home, a partir de "Before we render information about our orders".

lib/hello/orders.ex: o contexto como os dois geradores o deixaram, com os @doc gerados. Ele fica abaixo dos schemas de propósito: as funções usam %Order{}, e a struct precisa estar na sua forma final quando esta célula compila. Se você mudar os schemas de novo, avalie esta célula também (o notebook 09 mostrou o MatchError que aparece quando uma struct fica desatualizada).

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

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

  alias Hello.Orders.Order
  alias Hello.Accounts.Scope

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

  The broadcasted messages match the pattern:

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

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

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

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

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

  @doc """
  Returns the list of orders.

  ## Examples

      iex> list_orders(scope)
      [%Order{}, ...]

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

  @doc """
  Gets a single order.

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

  ## Examples

      iex> get_order!(scope, 123)
      %Order{}

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

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

  @doc """
  Creates a order.

  ## Examples

      iex> create_order(scope, %{field: value})
      {:ok, %Order{}}

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

  """
  def create_order(%Scope{} = scope, attrs) do
    with {:ok, order = %Order{}} <-
           %Order{}
           |> Order.changeset(attrs, scope)
           |> Repo.insert() do
      broadcast_order(scope, {:created, order})
      {:ok, order}
    end
  end

  @doc """
  Updates a order.

  ## Examples

      iex> update_order(scope, order, %{field: new_value})
      {:ok, %Order{}}

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

  """
  def update_order(%Scope{} = scope, %Order{} = order, attrs) do
    true = order.user_id == scope.user.id

    with {:ok, order = %Order{}} <-
           order
           |> Order.changeset(attrs, scope)
           |> Repo.update() do
      broadcast_order(scope, {:updated, order})
      {:ok, order}
    end
  end

  @doc """
  Deletes a order.

  ## Examples

      iex> delete_order(scope, order)
      {:ok, %Order{}}

      iex> delete_order(scope, order)
      {:error, %Ecto.Changeset{}}

  """
  def delete_order(%Scope{} = scope, %Order{} = order) do
    true = order.user_id == scope.user.id

    with {:ok, order = %Order{}} <-
           Repo.delete(order) do
      broadcast_order(scope, {:deleted, order})
      {:ok, order}
    end
  end

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

  ## Examples

      iex> change_order(scope, order)
      %Ecto.Changeset{data: %Order{}}

  """
  def change_order(%Scope{} = scope, %Order{} = order, attrs \\ %{}) do
    true = order.user_id == scope.user.id

    Order.changeset(order, attrs, scope)
  end

  alias Hello.Orders.LineItem

  @doc """
  Returns the list of order_line_items.

  ## Examples

      iex> list_order_line_items()
      [%LineItem{}, ...]

  """
  def list_order_line_items do
    Repo.all(LineItem)
  end

  @doc """
  Gets a single line_item.

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

  ## Examples

      iex> get_line_item!(123)
      %LineItem{}

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

  """
  def get_line_item!(id), do: Repo.get!(LineItem, id)

  @doc """
  Creates a line_item.

  ## Examples

      iex> create_line_item(%{field: value})
      {:ok, %LineItem{}}

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

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

  @doc """
  Updates a line_item.

  ## Examples

      iex> update_line_item(line_item, %{field: new_value})
      {:ok, %LineItem{}}

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

  """
  def update_line_item(%LineItem{} = line_item, attrs) do
    line_item
    |> LineItem.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a line_item.

  ## Examples

      iex> delete_line_item(line_item)
      {:ok, %LineItem{}}

      iex> delete_line_item(line_item)
      {:error, %Ecto.Changeset{}}

  """
  def delete_line_item(%LineItem{} = line_item) do
    Repo.delete(line_item)
  end

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

  ## Examples

      iex> change_line_item(line_item)
      %Ecto.Changeset{data: %LineItem{}}

  """
  def change_line_item(%LineItem{} = line_item, attrs \\ %{}) do
    LineItem.changeset(line_item, attrs)
  end
end

Repare em como o escopo atravessa o arquivo:

  • list_orders/1 usa Repo.all_by(Order, user_id: scope.user.id), e get_order!/2 usa Repo.get_by!(Order, id: id, user_id: scope.user.id): o dono faz parte da consulta;
  • update_order/3, delete_order/2 e change_order/3 começam com true = order.user_id == scope.user.id. Se alguém passar o pedido de outra pessoa, o casamento falha com MatchError, de propósito: é um erro de programação, não um caso a tratar;
  • subscribe_orders/1 e broadcast_order/2 usam o Phoenix.PubSub com um tópico por usuário, "user:ID:orders". Quem se inscreve (no módulo de LiveView, uma página aberta no navegador) recebe {:created, order}, {:updated, order} ou {:deleted, order} quando os pedidos daquele usuário mudam;
  • as funções do LineItem, geradas com --no-scope, não recebem escopo.

Para completar um pedido, o guia não usa o create_order/2 gerado:

Trilha.pergunta(:nome_da_funcao)

Completar um pedido, segundo o guia, exige três operações:

  1. gravar um pedido com o preço total;
  2. transformar cada item do carrinho em um item do pedido, com a quantidade e o preço do produto naquele momento;
  3. depois que o pedido for gravado (e, um dia, pago), esvaziar o carrinho.

A função do guia, em lib/hello/orders.ex:

alias Hello.Orders.LineItem
alias Hello.ShoppingCart

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

  line_items =
    Enum.map(cart.items, fn item ->
      %{
        product_id: item.product_id,
        price: item.product.price,
        quantity: item.quantity
      }
    end)

  order_changeset =
    Ecto.Changeset.change(%Order{}, %{
      user_id: scope.user.id,
      total_price: ShoppingCart.total_cart_price(cart),
      line_items: line_items
    })

  Repo.transact(fn ->
    with {:ok, order} <- Repo.insert(order_changeset),
         {:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
      {:ok, order}
    end
  end)
  |> case do
    {:ok, order} ->
      broadcast_order(scope, {:created, order})
      {:ok, order}

    {:error, reason} ->
      {:error, reason}
  end
end

Passo a passo:

  • cart.items vem com os produtos carregados (o get_cart/1 do notebook 10 faz os joins). Cada item vira um mapa com product_id, price (o preço do produto agora) e quantity;
  • Ecto.Changeset.change/2 monta um changeset sem cast e sem validações. Os dados não vêm de um formulário, e sim do próprio sistema. A chave line_items recebe a lista de mapas: como Order tem has_many :line_items, o Ecto entende cada mapa como um item novo e o insere junto com o pedido;
  • o total vem de ShoppingCart.total_cart_price/1. Um contexto pode chamar outro e trabalhar com dados de outro (aqui, o carrinho);
  • Repo.transact/2 executa as duas etapas numa transação: se a segunda falhar, a primeira é desfeita. A seção seguinte mostra isso no SQL;
  • no fim, broadcast_order/2 avisa os inscritos no tópico do usuário.

Sua vez: completar o pedido

Três mudanças:

  1. Na célula do Hello.ShoppingCart (seção "A aplicação Hello"), escreva prune_cart_items(scope, cart): apague todos os itens do carrinho com um só Repo.delete_all/1 e devolva {:ok, get_cart(scope)}, o carrinho recarregado. Avalie a célula.
  2. Na célula do Hello.Orders, faça get_order!/2 carregar os itens e os produtos de cada item, para a página do pedido: acrescente |> Repo.preload(line_items: [:product]) depois do Repo.get_by!.
  3. Na mesma célula, ponha a função complete_order/2 do guia logo depois da linha alias Hello.Orders.LineItem, com o alias Hello.ShoppingCart. Avalie a célula.

Se a célula do Orders for avaliada antes da do ShoppingCart, aparece o aviso Hello.ShoppingCart.prune_cart_items/2 is undefined or private: a função ainda não existe. Avalie as duas na ordem.

A conferência cria um usuário de teste com um carrinho de verdade (dois livros e um baralho), completa o pedido, olha as tabelas e depois apaga o que criou.

Trilha.conferir(:complete_order)
Trilha.solucao(:complete_order)

Erro como conteúdo: uma transação desfeita

Trilha.pergunta(:transacao)

A célula abaixo imita a estrutura de complete_order/2 com uma segunda etapa que falha, como um pagamento recusado. Leia o log: o INSERT do pedido acontece, entre um begin e um rollback.

bia = Hello.Accounts.get_user_by_email("bia@example.com")

Hello.Repo.transact(fn ->
  {:ok, pedido} =
    %Hello.Orders.Order{}
    |> Ecto.Changeset.change(user_id: bia.id, total_price: Decimal.new("35"))
    |> Hello.Repo.insert()

  IO.puts("Dentro da transação, o pedido #{pedido.id} existe.")
  {:error, :pagamento_recusado}
end)

O resultado é {:error, :pagamento_recusado}, o mesmo valor que a função devolveu. E o pedido que existia dentro da transação:

Trilha.consultar("SELECT id, user_id, total_price FROM orders")

A tabela não tem pedido nenhum. O id usado lá dentro, porém, não volta: a sequência do PostgreSQL que gera os ids não participa da transação. Por isso o próximo pedido gravado não vai ter o id 1, e é normal haver buracos na numeração.

A web: as rotas, o controller e a página do pedido

Na documentação: Bringing It Home, os trechos sobre o router e o OrderController. O guia acrescenta as rotas antes do migrate; aqui elas vêm junto do controller, que é quem precisa delas.

O guia acrescenta as rotas dos pedidos ao scope que exige login. Só duas actions são necessárias: create, que completa o pedido, e show, que o mostra.

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

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

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

+   resources "/orders", OrderController, only: [:create, :show]
  end

lib/hello_web/router.ex, com as rotas dos pedidos. A célula repete o router inteiro, porque um módulo não pode ser alterado aos pedaços; ela fica aqui, antes do controller, para que o ~p"/orders/#{order}" do controller encontre a rota. O aviso HelloWeb.OrderController.init/1 is undefined é esperado.

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

    # Brechó (notebook 08)
    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

    resources "/orders", OrderController, only: [:create, :show]
  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/order_controller.ex, com a action create do guia. O conn.assigns.cart vem do plug fetch_current_cart do router. Se complete_order/2 devolve {:ok, order}, o usuário vai para a página do pedido; qualquer {:error, _} o devolve ao carrinho com uma flash de erro. Se o exercício "Completar o pedido" ainda não foi feito, avaliar esta célula avisa Hello.Orders.complete_order/2 is undefined or private.

defmodule HelloWeb.OrderController do
  use HelloWeb, :controller

  alias Hello.Orders

  def create(conn, _) do
    case Orders.complete_order(conn.assigns.current_scope, conn.assigns.cart) do
      {:ok, order} ->
        conn
        |> put_flash(:info, "Order created successfully.")
        |> redirect(to: ~p"/orders/#{order}")

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

Erro como conteúdo: o pedido existe, a página não

O controller ainda não tem show. Mesmo assim, a Bia já pode completar o pedido:

Trilha.navegar("bia", :post, "/orders")

O primeiro passo deu certo: 302 para /orders/ seguido do id do pedido novo, com a flash "Order created successfully.". O pedido foi gravado, e o carrinho da Bia foi esvaziado. (O id não é 1: um se perdeu no rollback da seção anterior, e as conferências também gastam ids.) O segundo passo, o GET do redirect, levanta UndefinedFunctionError: function HelloWeb.OrderController.show/2 is undefined or private. O router achou a rota, e o controller não tem a action.

Se a mensagem fala de Hello.Orders.complete_order/2, o exercício "Completar o pedido" ainda não foi feito; volte a ele. E se você já fez o exercício abaixo e está avaliando o notebook de novo desde o começo, o erro é outro: no "show" html template defined for HelloWeb.OrderHTML (the module does not exist). A action existe, mas a célula da view vem depois desta.

Sua vez: a página do pedido

Três mudanças, todas com o código do guia:

  1. Na célula do OrderController, a action show:

    def show(conn, %{"id" => id}) do
      order = Orders.get_order!(conn.assigns.current_scope, id)
      render(conn, :show, order: order)
    end
    

    O escopo garante que cada usuário só vê os próprios pedidos.

  2. Na célula abaixo, o módulo HelloWeb.OrderHTML, com uma função show/1 que contém o template do guia (order_html/show.html.heex):

    <Layouts.app flash={@flash}>
      <.header>
        Thank you for your order!
        <:subtitle>
          <strong>Email: </strong>{@current_scope.user.email}
        </:subtitle>
      </.header>
    
      <.table id="items" rows={@order.line_items}>
        <:col :let={item} label="Title">{item.product.title}</:col>
        <:col :let={item} label="Quantity">{item.quantity}</:col>
        <:col :let={item} label="Price">
          {HelloWeb.CartHTML.currency_to_str(item.price)}
        </:col>
      </.table>
    
      <strong>Total price:</strong>
      {HelloWeb.CartHTML.currency_to_str(@order.total_price)}
    
      <.button navigate={~p"/products"}>Back to products</.button>
    </Layouts.app>
    

    O template reaproveita currency_to_str/1 do CartHTML. O preço de cada linha é item.price, o preço gravado no item, e não item.product.price.

  3. Na cópia do CartHTML, duas células abaixo, o botão que completa o pedido, no slot :actions do <.header>:

    <.header>
      My Cart
      <:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
      <:actions>
        <.button href={~p"/orders"} method="post">
          Complete order
        </.button>
      </:actions>
    </.header>
    

    Com href e method="post", o <.button> vira um link que o phoenix_html.js transforma em um POST com o token CSRF.

lib/hello_web/controllers/order_html.ex

# lib/hello_web/controllers/order_html.ex

lib/hello_web/controllers/cart_html.ex: o CartHTML da seção "A aplicação Hello", repetido aqui. O botão novo usa ~p"/orders", e esta célula vem depois do router que tem essa rota; editada lá em cima, a célula compilaria antes do router novo sempre que o notebook fosse avaliado do começo, com um aviso no route path. Edite esta cópia; a de cima fica como está.

# O botão Complete order vai no slot :actions do <.header> abaixo.
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
Trilha.conferir(:pagina_do_pedido)
Trilha.solucao(:pagina_do_pedido)

O pedido completo

Com a página pronta, a Bia faz uma compra do começo ao fim. Antes, esta célula inscreve o processo do notebook no tópico de pedidos da Bia, como uma LiveView faria, para ver o aviso do broadcast_order/2.

scope_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))
Hello.Orders.subscribe_orders(scope_da_bia)

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

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

O carrinho tem o botão "Complete order". Ela clica:

Trilha.navegar("bia", :post, "/orders")

O guia mostra uma linha "User uuid" nesta página, de uma versão antiga; o template do guia, o mesmo usado aqui, mostra o e-mail. Os preços das linhas são os do momento da compra, e o total é $35.00.

Enquanto isso, o aviso do PubSub chegou à caixa de mensagens do processo do notebook:

receive do
  {:created, pedido} -> "Chegou {:created, %Order{id: #{pedido.id}}} no tópico \"user:#{scope_da_bia.user.id}:orders\"."
after
  1000 -> "Nenhuma mensagem chegou."
end

O preço do momento

Trilha.pergunta(:preco_no_pedido)

A loja sobe o preço do livro para $20 pela API do catálogo, e a Bia abre de novo o pedido:

livro = Hello.Catalog.get_product!(1)
{:ok, _} = Hello.Catalog.update_product(livro, %{price: "20.00"})

Trilha.navegar("bia", :get, "/orders/#{Trilha.ultimo_pedido("bia@example.com")}")

O pedido continua com $15.00 na linha do livro e $35.00 no total. A página do produto já mostra o preço novo:

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

O carrinho se comporta de outro jeito. ShoppingCart.total_item_price/1 multiplica item.product.price, o preço atual, e não o price_when_carted que o notebook 10 guardou ao pôr o item no carrinho. O guia guarda esse preço "para detectar mudanças no futuro", mas não o usa ainda. Quem fecha a conta é complete_order/2, com o preço da hora do pagamento.

Erro como conteúdo: o pedido de outra pessoa

Trilha.pergunta(:pedido_alheio)

O Caio entra e abre o endereço do último pedido da Bia:

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

Trilha.navegar("caio", :get, "/orders/#{Trilha.ultimo_pedido("bia@example.com")}")

A consulta, que aparece na mensagem, procura o pedido com aquele id e com o user_id do Caio. Não há tal pedido, e Repo.get_by!/2 levanta Ecto.NoResultsError. Aqui, com debug_errors: true, o Phoenix mostra a página de depuração. Em produção, o usuário vê a página de erro 404. Quem diz ao Plug que essa exceção vale 404 é o phoenix_ecto, que implementa o protocolo Plug.Exception para ela:

Hello.Orders.Order
|> then(&Ecto.NoResultsError.exception(queryable: &1))
|> Plug.Exception.status()

O mesmo pacote dá status a outras exceções do Ecto: 400 para Ecto.CastError e Ecto.Query.CastError (um id como "abc", por exemplo) e 409 para Ecto.StaleEntryError.

Erro como conteúdo: o pedido vazio

Trilha.pergunta(:carrinho_vazio)

O carrinho da Bia ficou vazio depois do pedido. Ela clica em "Complete order" de novo:

Trilha.navegar("bia", :post, "/orders")

Um pedido de $0.00, sem nenhuma linha, foi gravado. (Se você já fez o exercício abaixo, a resposta é outra: a volta ao carrinho com a flash de erro.)

Sua vez: um pedido vazio

"Não existe pedido sem itens" é uma regra de negócio, e o lugar dela é o contexto que cuida de pedidos. Na célula do Hello.Orders, acrescente uma cláusula de complete_order/2, antes da que já existe, que case com um carrinho sem itens (%ShoppingCart.Cart{items: []}) e devolva {:error, :carrinho_vazio}. O OrderController não muda: ele já trata qualquer {:error, _}.

Trilha.conferir(:carrinho_vazio)
Trilha.solucao(:carrinho_vazio)

Com a regra no lugar, o mesmo clique da Bia volta para o carrinho:

Trilha.navegar("bia", :post, "/orders")

FAQ

Na documentação: FAQ

O último guia do bloco de modelagem de dados responde a três perguntas frequentes sobre contextos.

Quando usar os geradores?

Na documentação: When to use code generators?

Os guias usaram geradores para schemas, contextos e controllers. Se os padrões do Phoenix servem para você, pode contar com eles para boa parte da aplicação. A pergunta que o gerador obriga a responder é a mais importante: esta funcionalidade, com o seu schema, a sua tabela e os seus campos, pertence a um dos contextos que já existem ou a um novo? Foi o que este módulo fez a cada guia: produtos no Catalog, categorias também, carrinho em um contexto novo, pedidos em outro. Quando o nome do contexto não vem, use o plural do recurso (Products, Orders) e renomeie depois.

Como organizar o código dentro de um contexto?

Na documentação: How do I structure code inside contexts?

Trilha.pergunta(:faq_estrutura)

O contexto é a API pública; os módulos dentro dele são privados. Isso torna a organização interna uma decisão pequena. O Orders deste notebook tem as consultas no próprio orders.ex e os changesets nos schemas, como o gerador fez. Se um dia ele crescer, as consultas podem ir para um Orders.Queries, e nenhum controller vai notar.

Devolver estruturas do Ecto pela API do contexto

Na documentação: Returning Ecto structures from context APIs

Se o contexto existe para esconder o Repo, por que create_product/1 devolve um Ecto.Changeset quando falha? Porque o changeset não depende de banco. Ele acompanha mudanças, valida e guarda mensagens de erro para qualquer tipo de dado, venha ele de uma tabela, de uma API externa ou de um formulário que nem será gravado. Um changeset sem schema e sem tabela, só com tipos:

tipos = %{email: :string, mensagem: :string}

contato =
  {%{}, tipos}
  |> Ecto.Changeset.cast(%{"email" => "bia@example.com", "mensagem" => ""}, Map.keys(tipos))
  |> Ecto.Changeset.validate_required([:email, :mensagem])
  |> Ecto.Changeset.validate_length(:mensagem, max: 500)

{contato.valid?, contato.errors}

O mesmo changeset vira um formulário. O Phoenix não conhece o Ecto: ele define protocolos, e cada tipo de dado diz como se comporta. to_form/2 aceita um mapa simples ou um changeset; o campo impl mostra qual implementação do protocolo Phoenix.HTML.FormData foi usada:

{
  Phoenix.Component.to_form(%{"email" => "bia@example.com"}, as: :contato).impl,
  Phoenix.Component.to_form(contato, as: :contato).impl
}

A implementação para changesets, Phoenix.HTML.FormData.Ecto.Changeset, vem do pacote phoenix_ecto. É ela que leva os erros do changeset para os <.input>.

O outro protocolo citado pelo guia é Phoenix.Param, que transforma um valor no trecho de uma URL. É ele que faz ~p"/products/#{product}" virar /products/1:

Phoenix.Param.impl_for(Hello.Catalog.get_product!(1))

O guia diz que o phoenix_ecto implementa esse protocolo, mas no Phoenix 1.8.16 a implementação usada pelos schemas é a do próprio Phoenix, Phoenix.Param.Any: qualquer struct com o campo id serve. O phoenix_ecto implementa Phoenix.HTML.FormData (para changesets), Plug.Exception (para as exceções do Ecto, como visto acima) e Phoenix.HTML.Safe para Decimal, que é o que permite escrever {@product.price} num template.

Como o protocolo é aberto, uma struct que nunca passou pelo Ecto também pode ir para uma URL. Um livro identificado pelo ISBN:

defmodule Exemplo.Livro do
  @derive {Phoenix.Param, key: :isbn}
  defstruct [:isbn, :titulo]
end

defmodule Exemplo.Links do
  use HelloWeb, :verified_routes

  def produto(algo), do: ~p"/products/#{algo}"
end

A struct não pode ser usada na mesma célula que a define (o Elixir expande a célula inteira antes de executá-la), então o uso vem na célula seguinte:

{
  Exemplo.Links.produto(Hello.Catalog.get_product!(1)),
  Exemplo.Links.produto(%Exemplo.Livro{isbn: "978-1-68050-041-7", titulo: "Metaprogramming Elixir"})
}

@derive {Phoenix.Param, key: :isbn} gera a implementação do protocolo usando o campo isbn. Num projeto, os protocolos são consolidados na compilação e isso funciona do mesmo jeito; no notebook, a instalação desliga a consolidação (consolidate_protocols: false) para que implementações novas valham na hora.

Sem roteiro: as compras do Brechó

Agora o mesmo assunto sem passo a passo. O Brechó, o mini-marketplace da trilha, vende peças únicas: cada peça existe uma vez só. No notebook 08, a vitrine virou o contexto Hello.Vitrine, com uma venda atômica de uma peça. Agora uma pessoa logada compra várias peças de uma vez, e a compra precisa ser tudo ou nada.

O Brechó até aqui

As células abaixo são a solução do desafio do notebook 08: a tabela produtos (do notebook 07), o contexto Hello.Vitrine e as páginas da vitrine. Avalie-as.

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/controllers/produto_controller.ex e produto_html.ex: as rotas já estão no router.

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

Crie o contexto Hello.Compras:

  1. Uma migration, versão 20250323000000, com a tabela compras: user_id (referência a users, com on_delete: :delete_all e null: false), total_centavos (inteiro, null: false) e os timestamps. Como cada compra se liga às suas peças fica a seu critério: uma tabela de itens, como order_line_items, ou uma coluna compra_id em produtos. Escolha e saiba dizer por quê. (A solução usa uma tabela compra_itens com o preço de cada peça, pela mesma razão do guia: o preço da compra não muda se a vitrine mudar. E um índice único em compra_itens.produto_id garante, no banco, que uma peça só entra em uma compra.)
  2. A função Compras.comprar(scope, produto_ids), com escopo, como o guia fez com Orders. Dentro de um Repo.transact/2:
    • marque todas as peças pedidas como vendidas com um só Repo.update_all/3, que só pegue as que ainda não foram vendidas (where: not p.vendido), como o Vitrine.vender/1;
    • se o número de peças atualizadas for diferente do número de peças pedidas, alguma já tinha sido vendida: devolva {:error, :indisponivel}, e o transact desfaz tudo;
    • senão, grave a compra do usuário do escopo com o total e devolva {:ok, compra}.

O update_all com select: p devolve as peças atualizadas, com os preços: é de lá que sai o total.

# priv/repo/migrations/20250323000000_create_compras.exs
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/compras.ex (o contexto) e os schemas

Para experimentar: a Bia compra a jaqueta e o vestido, e depois tenta o vestido e a bota. Antes de você escrever o contexto, esta célula dá UndefinedFunctionError.

scope_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))

{
  Hello.Compras.comprar(scope_da_bia, [1, 2]),
  Hello.Compras.comprar(scope_da_bia, [2, 3])
}

A primeira compra dá certo, com o total de 12500 centavos. A segunda devolve {:error, :indisponivel}, porque o vestido já foi vendido, e a bota continua à venda.

A conferência compra duas peças, tenta uma compra com uma peça vendida e põe seis pessoas para disputar a mesma peça ao mesmo tempo. Ela cria e apaga as próprias peças e os próprios usuários.

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

No seu projeto

Repita o guia no projeto em que você fez os notebooks 08 a 10. A correspondência entre o notebook e o projeto:

Neste notebook No projeto
Células das migrations + Trilha.migracao/2 priv/repo/migrations/*_create_orders.exs e *_create_order_line_items.exs, criadas pelo gerador
Ecto.Migrator.run(..., :up, all: true) mix ecto.migrate
Ecto.Migrator.run(..., :down, to: VERSAO) mix ecto.rollback --to VERSAO
Célula do Hello.Orders lib/hello/orders.ex
Células dos schemas lib/hello/orders/order.ex e lib/hello/orders/line_item.ex
Célula do OrderHTML com show/1 lib/hello_web/controllers/order_html.ex com embed_templates "order_html/*" e o arquivo order_html/show.html.heex
Autenticação reduzida A gerada por mix phx.gen.auth Accounts User users, com login por e-mail e a caixa de correio em /dev/mailbox
Trilha.navegar("bia", ...) O navegador, logado com a sua conta
Trilha.recomecar() mix ecto.reset

Roteiro:

  1. Rode mix phx.gen.context Orders Order orders total_price:decimal e leia o config/config.exs para achar o escopo padrão que o gerador usou.
  2. Rode mix phx.gen.context Orders LineItem order_line_items price:decimal quantity:integer order_id:references:orders product_id:references:products --no-scope e responda y.
  3. Ajuste as duas migrations (decimal com precision: 15, scale: 6, null: false) e só então rode mix ecto.migrate.
  4. Troque os campos *_id pelas associações e, no iex -S mix, carregue os produtos de um pedido com Hello.Repo.preload(pedido, :products).
  5. Escreva complete_order/2, prune_cart_items/2 e o preload em get_order!/2; acrescente as rotas, o OrderController, o OrderHTML e o botão no carrinho.
  6. Faça um pedido pelo navegador. Depois, abra o endereço dele com outra conta e veja a página de depuração do Ecto.NoResultsError.
  7. Rode mix test test/hello/orders_test.exs. Os testes gerados usam fixtures com escopo (user_scope_fixture/0) e mostram como o gerador verifica que um usuário não enxerga os dados de outro (other_scope). Espere pelo menos uma falha: o teste de get_order!/2 compara o pedido da fixture, com line_items não carregados, com o pedido que a função devolve, agora com o preload. Os testes gerados descrevem o código gerado; quando o código muda, eles mudam junto. Ajuste-o, por exemplo comparando os id.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que acontece, passo a passo, entre o clique em "Complete order" e a página "Thank you for your order!". Cite o router, o plug do carrinho, o controller, o contexto, a transação e o template. Depois compare com os pontos abaixo.

  • Completar um pedido é uma operação de negócio, com nome próprio (complete_order/2) num contexto próprio (Orders). O controller só traduz a requisição em uma chamada ao contexto e a resposta em um redirect.
  • O escopo padrão, configurado pelo phx.gen.auth, fez o gerador pôr user_id no schema, scope em todas as funções e o dono dentro de cada consulta. Um usuário não acha os pedidos de outro: para ele, eles não existem (Ecto.NoResultsError, 404).
  • has_many :products, through: [:line_items, :product] é um caminho entre associações, sem tabela nova.
  • Ecto.Changeset.change/2 monta um changeset sem cast para dados que vêm do próprio sistema; uma lista de mapas numa associação has_many vira registros filhos inseridos junto.
  • Repo.transact/2 confirma tudo quando a função devolve {:ok, _} e desfaz tudo quando ela devolve {:error, _}. As sequências de ids não voltam atrás.
  • O item do pedido copia o preço do momento. O pedido não muda quando o catálogo muda.
  • Regras de negócio, como "não existe pedido vazio", moram no contexto; o banco e os changesets cuidam do formato dos dados.
  • Changesets não dependem do banco. O Phoenix conversa com eles e com qualquer outra estrutura por protocolos: Phoenix.HTML.FormData, para formulários, e Phoenix.Param, para URLs.

O módulo 6 termina aqui, com quatro contextos, cada um dono de uma parte dos dados:

Contexto Cuida de Tabelas
Catalog A exibição dos produtos: dados, categorias, contagem de visitas products, categories, product_categories
Accounts Quem é o usuário, e o Scope que viaja com ele users (e users_tokens, no gerado)
ShoppingCart O carrinho de cada usuário e as quantidades carts, cart_items
Orders Os pedidos completados, com o preço do momento orders, order_line_items

Os contextos se chamam (ShoppingCart usa Catalog.get_product!/1; Orders usa ShoppingCart.total_cart_price/1) e os schemas se ligam por chaves estrangeiras, mas cada um tem a sua API e as suas regras.

Próximo guia da trilha: JSON and APIs, que expõe esses mesmos contextos por uma API JSON.