Um painel para posts que eu ainda não publiquei
Depois que o scanner ligou a sincronização com o GitHub, o blog-manager finalmente tinha posts no banco de dados. Cerca de 100 deles, todos no estado not_imported / not_posted. Hora de colocar uma UI em cima.
O que isso precisa ser
O PRD pede uma única tela que liste todo post de todo blog registrado, filtrável por blog, status no Medium e status no LinkedIn, ordenável por data de publicação. Nada sofisticado. O objetivo deste app é me tirar do hábito de alternar entre abas do GitHub, do Medium e do LinkedIn para descobrir em que ponto do ciclo de vida cada post está, e o painel é o que responde essa pergunta.
Eu tinha duas formas de delimitar o escopo da issue:
- Construir a tabela agora, deixando o status de Medium/LinkedIn como um traço em todo lugar porque essas colunas ainda não existem, e deixar issues futuras preenchê-las.
- Adicionar as colunas de status de distribuição agora, junto com o painel.
Escolhi a (2). O PRD já tinha fixado o formato dos enums para as duas plataformas. O schema e a UI que o exibe deveriam viver no mesmo commit. Separá-los significaria uma sequência de dois PRs em que nenhuma das metades conta a história completa.
O schema
Uma migration, sem surpresas:
change_table :posts do |t|
t.integer :medium_status, null: false, default: 0
t.string :medium_draft_id
t.string :medium_url
t.datetime :medium_imported_at
t.integer :linkedin_status, null: false, default: 0
t.string :linkedin_post_id
t.datetime :linkedin_posted_at
t.text :linkedin_error
end
add_index :posts, :medium_status
add_index :posts, :linkedin_status
Dois enums baseados em inteiro no model, com prefixos para eu não ter colisão de nomes depois:
enum :medium_status, { not_imported: 0, draft: 1, published: 2 }, prefix: :medium
enum :linkedin_status, { not_posted: 0, posted: 1, failed: 2 }, prefix: :linkedin
O prefix: importa. Sem ele, o Rails gera published? no Post para o enum do Medium, e um dia vou adicionar um conceito de “published” que não tem nada a ver com o Medium, e essa sobreposição vai me morder. Com prefix: :medium eu tenho medium_published? e medium_draft?, e o model diz exatamente o que significa.
O truque do filtro
O controller pega os valores de filtro dos query params. Rails do jeito ingênuo:
scope = scope.where(medium_status: params[:medium_status]) if params[:medium_status].present?
Isso funciona até alguém (ou um fuzzer, ou eu mesmo com um typo) mandar ?medium_status=garbage. Com enums baseados em inteiro, o Rails lança ArgumentError: 'garbage' is not a valid medium_status. Com enums baseados em string, você teria um resultado vazio e um usuário confuso.
A correção é fazer allow-list contra as chaves conhecidas do enum antes de passar para o where:
scope = scope.where(medium_status: params[:medium_status]) if Post.medium_statuses.key?(params[:medium_status])
Post.medium_statuses retorna o hash {"not_imported"=>0, "draft"=>1, "published"=>2}. key? retorna true só para valores válidos. Entrada inválida passa direto sem erro e o usuário vê a lista sem filtro, que é o padrão certo para uma query string permissiva.
Mesma abordagem para a direção de ordenação:
ALLOWED_SORTS = %w[asc desc].freeze
direction = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort].to_sym : :desc
order(pub_date: params[:sort]) injetaria SQL alegremente se você deixasse. A allow-list é uma linha e evita que essa pergunta sequer precise ser feita.
O formulário que se autoenvia
Os filtros são selects. Eu quero que eles apliquem no change, sem precisar de um botão de enviar. O caminho mais curto:
<select name="medium_status" onchange="this.form.submit()">
O Rails 8 já vem com o Turbo ativado por padrão. form.submit() chamado do JS ainda é interceptado; o Turbo Drive cuida da navegação, a URL é atualizada com os novos params, e a página troca sem reload completo. Nenhum data attribute é necessário.
Cheguei quase a usar Stimulus para isso. O controller inteiro seria três linhas chamando event.target.form.requestSubmit(). O JS puro faz a mesma coisa com menos código. O Stimulus se justifica quando há comportamento de verdade para encapsular; “enviar o formulário no change” não é esse caso.
Testes que exercitam a superfície de URL
Os specs do controller não checam só se a página renderiza. Eles checam a ordem dos títulos no corpo da resposta para confirmar que a ordenação funcionou, e checam que filtrar por blog produz só os posts daquele blog:
test "default sort is pub_date desc" do
get posts_url
body = @response.body
assert body.index("Bravo") < body.index("Charlie"),
"Bravo (Jun) should come before Charlie (Mar)"
end
test "invalid medium_status is ignored (returns full list)" do
get posts_url(medium_status: "garbage")
assert_response :success
assert_select "td", text: "Alpha"
assert_select "td", text: "Charlie"
end
Fazer index-of no corpo da resposta é bruto. Funciona porque o único lugar onde esses títulos aparecem é nas células da tabela, e isso me diz o que o usuário realmente vê. Um teste que verifica a ordem de assigns(:posts) passaria mesmo que a view esquecesse de renderizá-los.
10 testes de controller, 6 testes de model, tudo verde. Suíte total: 70 runs, 195 assertions.
O que vem a seguir
Este é o quarto commit de feature. O app agora tem: autenticação, gerenciamento de blogs, scanning de posts, painel de posts. A próxima camada é o trabalho por blog: escolher uma imagem do Pexels para um post, enviá-lo ao Medium como rascunho, acompanhar a publicação, postar no LinkedIn. Cada uma dessas etapas se encaixa na tabela que acabei de construir. Elas atualizam valores de enum que o painel já sabe exibir.
Se eu quisesse lançar a v1 hoje, esse painel mais um botão “Importar como rascunho” no Medium já seria o mínimo útil. Mais duas issues de trabalho, três no máximo. Isso é um caminho mais rápido até uma ferramenta funcional do que eu esperava quando comecei esse PRD.
A régua estabelecida pelo scanner continua valendo: 335 linhas novas para essa issue, nenhuma gem nova.
Leitura relacionada
O que acontece quando um job faz broadcast para ninguém
Fechando o loop da imagem de destaque: frontmatter só por inserção, uma guarda que pegou divergência real na primeira execução, e um broadcast sem ouvinte.
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Você também pode achar útil
Proton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisProton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais