Un panel para posts que todavía no publiqué
Después de que el escáner conectara la sincronización con GitHub, blog-manager por fin tenía posts en su base de datos. Cerca de 100, todos en estado not_imported / not_posted. Hora de ponerles una interfaz encima.
Lo que esto tiene que ser
El PRD pide una sola pantalla que liste todos los posts de todos los blogs registrados, filtrable por blog, estado de Medium y estado de LinkedIn, ordenable por fecha de publicación. Nada complicado. El objetivo de esta app es evitar el cambio constante entre pestañas de GitHub, Medium y LinkedIn para averiguar en qué punto de su ciclo de vida está cada post, y el panel es lo que responde esa pregunta.
Tenía dos maneras de definir el alcance del issue:
- Construir la tabla ahora, poner un guion como placeholder para el estado de Medium/LinkedIn en todos lados porque esas columnas todavía no existen, y dejar que issues futuros las completen.
- Agregar las columnas de estado de distribución ahora, junto con el panel.
Elegí la (2). El PRD ya fijaba la forma de los enums para ambas plataformas. El esquema y la interfaz que lo muestra tienen que vivir en el mismo commit. Separarlos habría significado una secuencia de dos PRs donde ninguna mitad cuenta la historia completa.
El esquema
Una sola migración, sin sorpresas:
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
Dos enums respaldados por enteros en el modelo, con prefijos para no toparme con colisiones de nombres más adelante:
enum :medium_status, { not_imported: 0, draft: 1, published: 2 }, prefix: :medium
enum :linkedin_status, { not_posted: 0, posted: 1, failed: 2 }, prefix: :linkedin
El prefix: importa. Sin él, Rails genera published? en Post para el enum de Medium, y algún día voy a agregar un concepto de “published” que no tiene nada que ver con Medium, y esa colisión me va a morder. Con prefix: :medium obtengo medium_published? y medium_draft?, y el modelo dice exactamente lo que significa.
El truco del filtro
El controlador toma los valores de filtro desde los query params. Rails ingenuo:
scope = scope.where(medium_status: params[:medium_status]) if params[:medium_status].present?
Eso funciona hasta que alguien (o un fuzzer, o yo mismo con un typo) manda ?medium_status=garbage. Con enums respaldados por enteros, Rails lanza ArgumentError: 'garbage' is not a valid medium_status. Con enums respaldados por strings, se obtendría un resultado vacío y un usuario confundido.
La solución es validar contra una lista blanca de las claves conocidas del enum antes de pasarlo a where:
scope = scope.where(medium_status: params[:medium_status]) if Post.medium_statuses.key?(params[:medium_status])
Post.medium_statuses devuelve el hash {"not_imported"=>0, "draft"=>1, "published"=>2}. key? devuelve true solo para valores válidos. Una entrada inválida pasa de largo en silencio y el usuario ve la lista sin filtrar, que es el comportamiento por defecto correcto para un query string permisivo.
El mismo enfoque para la dirección de orden:
ALLOWED_SORTS = %w[asc desc].freeze
direction = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort].to_sym : :desc
order(pub_date: params[:sort]) inyectaría SQL feliz de la vida si se lo permitiera. La lista blanca es una sola línea y evita que esa pregunta siquiera llegue a plantearse.
El formulario que se envía solo
Los filtros son menús desplegables. Quería que se aplicaran al cambiar, sin necesitar un botón de enviar. El camino más corto:
<select name="medium_status" onchange="this.form.submit()">
Rails 8 viene con Turbo activado por defecto. form.submit() desde JS igual queda interceptado; Turbo Drive maneja la navegación, la URL se actualiza con los nuevos params, y la página se reemplaza sin recarga completa. No hace falta ningún atributo data.
Casi recurro a Stimulus para esto. Todo el controlador habría sido tres líneas que llamaban a event.target.form.requestSubmit(). El JS puro hace lo mismo con menos código. Stimulus se justifica cuando hay comportamiento real para encapsular; “enviar el formulario al cambiar” no es ese caso.
Pruebas que ejercitan la superficie de la URL
Los specs del controlador no solo verifican que la página se renderice. Verifican el orden de los títulos en el cuerpo de la respuesta para confirmar que el orden funcionó, y verifican que filtrar por blog produzca solo los posts de ese 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
Buscar el índice de una cadena en el cuerpo es rudimentario. Funciona porque el único lugar donde aparecen estos títulos es en las celdas de la tabla, y me dice lo que el usuario realmente ve. Una prueba que verificara el orden de assigns(:posts) pasaría incluso si la vista se olvidara de renderizarlos.
10 pruebas de controlador, 6 de modelo, todas en verde. Suite total: 70 corridas, 195 aserciones.
Lo que sigue
Este es el cuarto commit de funcionalidad. La app ahora tiene: autenticación, gestión de blogs, escaneo de posts, panel de posts. La siguiente capa es el trabajo por blog: elegir una imagen de Pexels para un post, enviarlo a Medium como borrador, esperar la publicación, publicarlo en LinkedIn. Cada uno encaja en la tabla que acabo de construir. Actualizan valores de enum que el panel ya sabe mostrar.
Si quisiera publicar la v1 hoy, este panel más un botón de “Import as draft” para Medium sería lo mínimo útil. Dos issues más de trabajo, tres como mucho. Es un camino más rápido hacia una herramienta funcional de lo que esperaba cuando empecé este PRD.
El estándar del escáner se mantiene: 335 líneas nuevas para este issue, sin gemas nuevas.
Lecturas relacionadas
Qué pasa cuando un job transmite y nadie escucha
Cerrando el ciclo de la imagen destacada: frontmatter que solo inserta, una validación que detectó drift real, y un broadcast sin oyentes.
Una corrección de desfase de documentación que no fue tan aburrida como sonaba
Tres puntos de auditoría que se volvieron algo más: una afirmación a medio corregir, un restablecimiento de contraseña muerto en silencio, y un correo de staging que enlazaba a producción.
Un 500 escondido dentro de las rutas aisladas de un engine montado
El dashboard de jobs devolvía un 500 en vez de una página de login: los route helpers sin calificar se resuelven contra el engine, no contra la app. Una línea, más su gemela dormida.
También te podría ser útil
Proton Mail
Correo electrónico cifrado de extremo a extremo, con arquitectura de acceso cero.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más informaciónAdGuard para iOS
Bloqueo de anuncios y rastreadores en todo el sistema en iOS, sin necesidad de un servidor DNS aparte.
Como afiliado de AdGuard, obtengo ingresos por las compras que califican.
Más informaciónProton VPN
VPN comercial con filtrado NetShield e interruptor de apagado automático.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más información