Turnia: SaaS de turnos para barberías, en tres partes
Landing, app de gestión y bot de Telegram sobre la misma base de datos
Producto ficticio de portafolio con código y despliegue reales: landing en Next.js, agenda con drag & drop y ⌘K sobre Postgres, y un bot de Telegram que reserva y recuerda turnos. Todo en vivo; los datos de la demo se reinician cada noche.
Contexto
Una barbería vive de su agenda: turnos que se pisan, clientes que no confirman, cambios de último momento y la caja del día. Turnia es el ejercicio de resolver eso como producto completo —vender, operar y atender por chat— sin duplicar reglas de negocio en tres lugares, y con un bot que cualquiera puede escribir sin tirar abajo el servidor.
Solución
Una sola app Next.js 16 con Drizzle sobre Postgres (Neon) hace todo el trabajo: agenda por profesional (crear en hueco, arrastrar, estirar, ⌘K que crea turnos por texto, deshacer), clientes, membresías y caja del día; link público de reserva con disponibilidad real.
El bot es una máquina de estados pura dentro de la app; n8n solo es el canal entre Telegram y la API.


Arquitectura
El cliente escribe al bot en Telegram
Caddy solo deja pasar las subredes de Telegram y cuerpos de hasta 64 KB
n8n normaliza el mensaje (tope global de 60 por minuto) y lo manda a la API con clave
La app corre la máquina de estados y responde con texto y botones (30 por chat y hora)
El turno se guarda en Postgres y aparece en la agenda
Un cron de n8n pide los recordatorios de 24 h y 2 h; el de 24 h trae «Confirmo» / «Cambiar»
Piezas
- Next.js 16 en Vercel: landing, app
/app/*, reserva pública y API/api/bot/* - Postgres en Neon con Drizzle ORM (driver serverless en producción)
- n8n: bot, recordatorios, reserva pública y lista de espera
- Caddy delante de n8n, con filtro de subredes de Telegram
- Cron de Vercel para reiniciar la demo
Decisiones técnicas
El cerebro vive en la app; n8n es el canal
Por qué: las reglas de disponibilidad, choques y caja existen una sola vez y las usan la agenda, la reserva pública y el bot; pasar de Telegram a WhatsApp es cambiar dos nodos.
Trade-off la app tiene que exponer una API propia, protegida por clave, y mantenerla.
Defensa en tres capas para un bot público
Borde: Caddy responde 403 fuera de las subredes de Telegram. n8n: tope global de 60 updates por minuto. App: 30 mensajes por chat y hora y 200 por local y día, con respuesta 429.
Trade-off si Telegram agrega rangos de IP, el bot deja de responder hasta actualizar el filtro.
Recordatorios idempotentes
Al listar, la API crea el mensaje en estado «pendiente» y no lo repite si ya existe para ese turno. Por qué: el cron puede correr dos veces sin mandar duplicados.
Trade-off una tabla más de estado en Postgres.
Demo pública que se reinicia sola
Un cron de Vercel diario (07:00 UTC) llama a un endpoint con
Bearer CRON_SECRETy devuelve al seed solo el local demo. Por qué: cualquiera puede entrar y editar sin dejarla rota.Trade-off lo que haga un visitante se borra esa noche.
Reglas de choque en el servidor, interfaz optimista
El arrastre mueve el turno al instante; si el servidor detecta un choque, la tarjeta vuelve a su lugar con un aviso.
Trade-off hay que mantener la lógica de revertir en el cliente.
Resultados
- Landing, app y bot en vivo (HTTP 200 el 2-oct-2026)
- Sin cortes de layout en 7 anchos, de 320 a 2560 px
- 0 errores y 0 avisos de consola; 0 peticiones externas
- Build en verde, tsc y eslint limpios
- Prueba automatizada del bot: 401, 400, reserva completa, recordatorio sin duplicar, 409 por chat ajeno y los dos límites (31 llamadas reales)
Stack
- Next.js 16
- React 19
- TypeScript
- Tailwind 4
- Drizzle ORM
- Postgres (Neon)
- Server Actions
- n8n
- Telegram Bot API
- Caddy
- Vercel


