← Volver al portfolio← Back to portfolio
Caso de estudioCase study

JobBot

Un SaaS de búsqueda laboral que junta ofertas de varias fuentes, las filtra por tu perfil y te avisa por Telegram para que postules en las primeras horas. Lo construí solo, de punta a punta: API, dashboard, bot, pagos, CI y deploy.

A job-search SaaS that pulls openings from several sources, filters them against your profile and pings you on Telegram so you apply within the first hours. I built it solo, end to end: API, dashboard, bot, payments, CI and deploy.

5 sem.del primer commit (8 mar) a beta (11 abr 2026)first commit (Mar 8) to beta (Apr 11, 2026)
81commits, todos míoscommits, all mine
4servicios: landing, app, API y botservices: landing, app, API and bot
3pipelines de CI (tests, e2e, chequeos)CI pipelines (tests, e2e, repo checks)

El problema

The problem

Buscar trabajo es un desgaste con tres fugas concretas: se pierden oportunidades (decenas de ofertas nuevas por semana repartidas en sitios distintos), se aplica tarde (las primeras 24 horas deciden la mayoría de las entrevistas) y no hay seguimiento (horas refrescando LinkedIn sin saber qué funcionó). Lo viví buscando mi propia primera experiencia en tecnología.

Job hunting leaks in three concrete places: you miss openings (dozens of new posts a week spread across different sites), you apply late (the first 24 hours decide most interviews) and there's no follow-up (hours refreshing LinkedIn with no idea what worked). I lived it looking for my own first tech role.

Qué hace hoy

What it does today

Arquitectura

Architecture

La landing vende y deriva tráfico; el producto autenticado vive en el dashboard. Son dos deploys separados a propósito.

The landing sells and routes traffic; the authenticated product lives in the dashboard. Two separate deploys, on purpose.

Decisiones técnicas

Technical decisions

Separar landing y app

Separate landing and app

La landing es HTML estático: carga instantánea y cambios de copy sin tocar el producto. El dashboard es Next.js con auth. Así un error de marketing no puede romper el login, y viceversa.

The landing is static HTML: instant load and copy changes without touching the product. The dashboard is Next.js with auth. A marketing mistake can't break login, and vice versa.

Cookies httpOnly en vez de tokens en localStorage

httpOnly cookies instead of tokens in localStorage

Un token en localStorage lo lee cualquier script que se cuele en la página. En una cookie httpOnly, JavaScript no puede leerlo. Sumé refresh tokens agrupados en "familias" para detectar si alguien reutiliza uno robado y revocar toda la sesión.

A token in localStorage can be read by any script that sneaks into the page; an httpOnly cookie can't be read by JavaScript at all. I added refresh tokens grouped into "families" so reuse of a stolen one is detected and the whole session revoked.

Muchas fuentes, ninguna imprescindible

Many sources, none essential

Los sitios de empleo cambian o bloquean sin aviso. Cada fuente es independiente, y LinkedIn tiene un camino de respaldo por RSS: si una se cae, las alertas siguen llegando con las demás.

Job sites change or block without warning. Each source is independent, and LinkedIn has an RSS fallback path: if one breaks, alerts keep arriving from the rest.

Dos pasarelas de pago

Two payment gateways

Stripe para tarjetas internacionales y MercadoPago porque es lo que usa la gente en Argentina. Las dos confirman el pago a la API por webhook, así que el plan se activa aunque el usuario cierre la pestaña.

Stripe for international cards and MercadoPago because it's what people actually use in Argentina. Both confirm payment to the API via webhook, so the plan activates even if the user closes the tab.

Créditos para lo que cuesta IA

Credits for anything that costs AI

El análisis de CV llama a un modelo de IA con costo por uso. Un sistema de créditos evita que un plan fijo termine regalando lo que cuesta plata.

CV analysis calls a pay-per-use AI model. A credits system keeps a flat plan from giving away what costs money.

CI con base de datos real

CI against a real database

Los tests corren en GitHub Actions contra PostgreSQL y Redis reales, no contra mocks. Si una migración rompe algo, falla el pipeline, no el usuario.

Tests run in GitHub Actions against real PostgreSQL and Redis, not mocks. If a migration breaks something, the pipeline fails, not the user.

Qué salió mal (y cómo lo encontré)

What went wrong (and how I found it)

Antes del lanzamiento hice una revisión de código y una auditoría de seguridad sobre mi propio proyecto (los informes están en el repo). La nota global fue 72/100. Estos son hallazgos reales:

Before launch I ran a code review and a security audit on my own project (the reports are in the repo). The overall score was 72/100. These are real findings:

Comparación del login de Telegram vulnerable a timing attacksTelegram login check open to timing attackscorregidofixed

Comparar un hash con == tarda distinto según cuántos caracteres coinciden, y eso filtra información. Detectado el 2 de abril; el 9 pasó a hmac.compare_digest, que compara en tiempo constante.

Comparing a hash with == takes a different time depending on how many characters match, which leaks information. Found on April 2; by April 9 it used hmac.compare_digest, which runs in constant time.

La detección de tokens robados nunca podía activarseStolen-token detection could never firecorregidofixed

Cada renovación de sesión creaba una "familia" de tokens nueva en vez de continuar la anterior. Como la detección de reuso compara dentro de una misma familia, un token robado nunca iba a disparar la alarma. Ahora la rotación mantiene la familia y guarda qué token reemplazó a cuál.

Every session refresh started a brand-new token "family" instead of continuing the old one. Since reuse detection works within a family, a stolen token could never trip it. Rotation now keeps the family and records which token replaced which.

Dos funciones distintas actualizaban el plan del usuarioTwo different functions updated a user's plancorregidofixed

Había una update_user_plan duplicada, así que según qué camino tomaba el pago, los límites del plan quedaban distintos. Dejé una sola y agregué un test que cubre los límites comerciales.

A duplicated update_user_plan meant plan limits depended on which payment path ran. I kept one and added a test covering the commercial limits.

La landing prometía cosas que el producto no medíaThe landing promised things the product didn't measurecorregidofixed

Armé una matriz que contrasta cada frase de la landing con el código de la API. Decía "15+ portales" (eran 6), mostraba "42 empleos esta semana" (dato de demo) y "Top 10% de usuarios" (nada lo medía). Los corregí o los saqué, y los precios quedaron tomados de la API como única fuente.

I built a matrix checking every landing claim against the API code. It said "15+ job boards" (there were 6), showed "42 jobs this week" (demo data) and "top 10% of users" (nothing measured it). I corrected or removed them, and prices now come from the API as the single source.

Un secreto mal configurado se reporta como error 500A misconfigured secret surfaces as a 500 errorpendienteopen

Si falta la clave para firmar los tokens, la API debería negarse a arrancar. Hoy arranca igual y recién falla cuando alguien intenta iniciar sesión. Es el próximo arreglo; lo dejo acá porque no quiero mostrar solo lo que salió bien.

If the token signing key is missing, the API should refuse to start. Today it starts anyway and only fails when someone tries to log in. It's the next fix; I'm listing it because I don't want to show only what went right.

Qué aprendí

What I learned

← Volver al portfolio← Back to portfolio