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
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.
Apagando código morto, e pegando um motivo errado para uma resposta certa
Uma issue de limpeza com uma justificativa errada, uma varredura de documentação que não era necessária, e a credencial órfã que uma revisão pegou.
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