01-overview.md
Visión general · Conciergo
Conciergo es una web mobile-first para encontrar con quién ir a un concierto. Cada bolo tiene su ficha y su marcador en el mapa. Al apuntarte, la app te conecta con cada persona que también va y os abre un chat para quedar antes. No hay swipes ni perfiles que puntuar: ir al mismo concierto es la única condición.
El cartel y el mapa se ven sin cuenta. Apuntarse, añadir un bolo, ver tus conexiones, chatear y editar el perfil piden sesión con Neon Auth (email y contraseña, o Google).
El repo se llama Concert-Connect; la marca es Conciergo (en la interfaz, ConcierGo). Es un workspace pnpm con un front React + Vite, una API Express y un contrato OpenAPI del que se generan los hooks del cliente y los esquemas Zod.
Enlaces
- Web: conciergo-sigma.vercel.app
- GitHub: Criscode2022/Concert-Connect
- Salud de la API:
/api/healthzresponde{"status":"ok"}
Stack
| Capa | Tecnología |
|---|---|
| Web | React 19.1, Vite 7, Tailwind CSS 4, componentes shadcn/ui (Radix), Wouter 3, TanStack Query 5, Framer Motion |
| Contrato | OpenAPI 3.1 (lib/api-spec/openapi.yaml); Orval genera los hooks de React Query y los esquemas Zod |
| API | Express 5 en Node, logs con pino; Socket.io para el chat cuando la API corre como servidor |
| Auth | Neon Auth (Better Auth). La API verifica el JWT con jose contra el JWKS de Neon Auth |
| Datos | Neon Postgres en Frankfurt (eu-central-1) con Drizzle ORM sobre pg |
| Mapa | Google Maps JavaScript API con marcadores avanzados |
| Deploy | Vercel: el front como estáticos y la API como una sola función |
| Monorepo | pnpm 11 con workspaces y catálogo de versiones, TypeScript 5.9, Node 22 o superior |
En cifras
| Rutas del front | 10: landing, acceso, eventos, mapa, nuevo bolo, ficha, conexiones, chat, perfil y perfil de otra persona |
| Operaciones de la API | 15 en el contrato OpenAPI |
| Tablas | 5: users, events, event_attendees, matches, messages |
| Bolos en producción | 6, en Madrid y Barcelona, uno por género (20 de octubre de 2026) |
| Tests | Ninguno automatizado. Ver Testing · visión |
Historia
El proyecto nació en Replit en junio de 2026 con Clerk como login. En agosto pasó a Vercel y Neon Auth (Replace Clerk and Replit with Neon Auth and Vercel), y en septiembre la base de datos se movió a un proyecto Neon en Frankfurt. Quedan restos de Replit que no se despliegan: artifacts/mockup-sandbox, attached_assets/ y scripts/post-merge.sh.
Cómo leer esta documentación
| Documento | Para |
|---|---|
| Producto y features | Qué hace la app, pantalla por pantalla |
| Arquitectura | Cómo viaja una petición y cómo llega un mensaje |
| Estructura del workspace | Qué hay en cada paquete y qué comandos existen |
| Superficie API | Las 15 operaciones y el handshake del chat |
| Autenticación | Del login de Neon Auth al usuario de la base de datos |
| Modelo de datos | Tablas, borrados en cascada y cómo se crea el esquema |
| Deploy en Vercel | Cómo llega main a producción y cómo se arranca local |
| Decisiones | Por qué está hecho así |
| Retos y resultados | Lo que costó, lo que funciona y lo que falta |
| Front · React | Las prácticas del cliente React |
La parte de testing empieza en Testing · visión.
02-product.md
Producto y features
Problema
Un concierto al que te apetece ir se queda en nada si nadie de tu entorno se apunta. Los grupos de mensajería no conectan a desconocidos que tienen el mismo plan, y las apps de citas no van de música.
Solución
En Conciergo, apuntarse a un bolo es el gesto que conecta: la app te empareja con cada persona que también va y os abre un chat para quedar antes, compartir el viaje o guardar sitio delante.
Quién la usa
- Sin cuenta: mira el cartel, el mapa y la ficha de cada bolo. El botón de apuntarse le pide iniciar sesión.
- Con cuenta: se apunta y se borra de bolos, añade bolos nuevos, chatea con sus conexiones y edita su perfil.
La interfaz está en español, y las fechas también (date-fns con locale es).
Pantallas
Landing (/)
Titular de cartel, «No dejes que un gran bolo se pierda», y tres salidas: Entrar, Unirse y Explorar eventos. Con la sesión abierta, redirige a /events.
Acceso (/auth/sign-in, /auth/sign-up)
Las vistas de @neondatabase/auth-ui con el estilo de la app: email y contraseña o Google, nombre obligatorio al registrarse y recuperación de contraseña. Al entrar, lleva a /events. /sign-in y /sign-up redirigen aquí.
Eventos (/events)
Los bolos ordenados por fecha descendente, el más tardío primero (la API devuelve hasta 50). Cada tarjeta enseña artista, recinto, fecha y asistentes, con el botón de apuntarse. Encima, chips de género construidos con los géneros de los bolos cargados; tocar uno filtra la lista, y tocar el género de una tarjeta hace lo mismo. Mientras llegan los datos y las imágenes se ve un esqueleto, para que la lista no salte. Con sesión, un botón flotante lleva a añadir un bolo.
Mapa (/events/map)
Google Maps centrado en Madrid con zoom de país, un marcador por bolo. Al tocar uno se abre su ficha resumida con el botón de apuntarse o cancelar. Si falta la clave de Maps, la página lo dice en lugar de romperse.
Nuevo bolo (/events/new)
Formulario con validación Zod: título, artista, recinto y fecha obligatorios; género y URL de imagen opcionales. Quien crea el bolo queda apuntado. El formulario no pide la ubicación y envía lat: 0 y lng: 0, así que un bolo creado desde la app no sale en el mapa de España (ver Retos y resultados).
Ficha de un bolo (/events/:id)
Datos del bolo, número de asistentes y el botón Apuntarme al bolo / Apuntado · Cancelar. Al apuntarte aparece el bloque Tus Compañeros. Sin sesión, el botón dice Inicia sesión para apuntarte.
Conexiones (/matches)
Una fila por persona y bolo, con su avatar, su nombre, el artista, el último mensaje con su hora y un contador de mensajes recibidos. La lista se vuelve a pedir cada 20 segundos y la suma de los contadores sale como badge en la navegación. Sin conexiones, la pantalla dice «El backstage está vacío».
Chat (/matches/:matchId)
Cabecera con el bolo, mensajes con su hora y un campo que envía con Intro. Un chat vacío invita a romper el hielo.
Perfil (/profile) y perfil de otra persona (/profile/:userId)
El perfil propio edita nombre, bio, ciudad y géneros favoritos (16 para elegir); el avatar viene de la cuenta de Neon Auth, y desde aquí se cierra la sesión. El de otra persona, Backstage de…, enseña su avatar, ciudad, bio y géneros.
Reglas
- Apuntarse crea las conexiones: una por cada otra persona apuntada al mismo bolo. Una pareja tiene una sola conexión por bolo.
- Borrarse las quita: al dejar de ir, se borran tus conexiones de ese bolo y, con ellas, sus mensajes. Solo chatea quien sigue apuntado.
- El chat es privado: solo las dos personas de una conexión pueden leerlo o escribir en él.
- Navegación: barra lateral en escritorio y barra inferior en el móvil. Sin sesión solo se ven Eventos y Mapa.
03-architecture.md
Arquitectura
Vista general
Navegador · SPA React (Vite)
│ sesión de Neon Auth (cookie) ──► Neon Auth: login, Google, JWT
│ hooks de Orval + Bearer JWT
▼
Vercel
├─ estáticos: artifacts/conciergo/dist/public
└─ /api/* ──► función api/index.js ──► app Express (dist/app.mjs)
│ requireAuth / optionalAuth: JWT verificado con el JWKS
▼
Neon Postgres (Drizzle + pg)
users · events · event_attendees · matches · messages
El mapa carga la Google Maps JavaScript API directamente desde el navegador.
Cómo viaja una petición
- Hook generado. Una página llama a un hook de
@workspace/api-client-react, por ejemplouseAttendEvent(). Orval lo genera desdeopenapi.yaml. - Token. El
customFetchdel paquete pide el token agetAccessToken()(registrado consetAuthTokenGetter) y añadeAuthorization: Bearer <jwt>. Sin sesión, la petición sale sin cabecera. - Vercel. La regla
/api/:path*→/api/indexmanda la petición a la función, que importa la app Express ya compilada y la reutiliza entre peticiones. - Middleware.
requireAuthverifica el JWT y responde 401 si falta o no vale;optionalAuthdeja pasar a los invitados. Los dos crean el usuario en la tablausersla primera vez que lo ven (ver Autenticación). - Ruta. La ruta consulta Drizzle y da forma a la respuesta con funciones como
eventToJsonouserToJson: fechas en ISO, nulos explícitos y campos calculados (attendeesCount,isAttending,unreadCount). - Caché. TanStack Query guarda la respuesta. Tras apuntarse o borrarse,
invalidateAfterAttendance()invalida eventos y conexiones para que la siguiente pantalla no enseñe datos viejos.
Dos formas de arrancar la API
artifacts/api-server/build.mjs compila con esbuild dos entradas:
| Fichero | Qué es | Dónde se usa |
|---|---|---|
dist/app.mjs | Solo la app Express, sin listen | La función de Vercel (api/index.js) |
dist/index.mjs | Servidor HTTP en PORT (8080) con Socket.io | En local, con pnpm --filter @workspace/api-server run dev |
Separar la app del servidor es lo que permite meter Express en una función. El precio es el chat en vivo: Socket.io necesita un servidor que siga abierto, y en Vercel no lo hay.
Cómo llega un mensaje
- El cliente envía
POST /api/matches/:matchId/messages. - La API comprueba que la persona pertenece a la conexión, guarda el mensaje y llama a
broadcastMessage(). - En local, Socket.io emite
new_messagea la salachat:<matchId>y el otro navegador lo añade a la caché al instante. - En Vercel no hay servidor de Socket.io (
/api/socket.ioresponde 404), así quebroadcastMessage()no hace nada. El otro lado ve el mensaje cuando su caché se refresca: al volver a la pestaña, al reconectar o al abrir el chat. La lista de conexiones se vuelve a pedir cada 20 segundos.
Estado en el cliente
- Sesión: la gestiona el cliente de Neon Auth (
authClient.useSession()). - Datos: todo pasa por TanStack Query. Por defecto, los datos valen 15 segundos, se refrescan al volver a la ventana y al reconectar, un 401 se reintenta hasta tres veces (el token puede estar llegando) y el resto de errores 4xx no se reintenta.
- Cambio de cuenta:
AuthQueryClientCacheInvalidatorvacía la caché cuando una sesión abierta se cierra o cambia de usuario, para que nadie vea las conexiones de la cuenta anterior.
Base de datos
lib/db exporta db como un Proxy: el pool de pg (máximo 5 conexiones) se crea en la primera consulta. Así importar el paquete no exige DATABASE_URL, y una función de Vercel que solo responde /api/healthz no abre conexiones.
Carga del front
Cada página es un lazy() con su Suspense, y Vite separa en trozos propios React, TanStack Query y Framer Motion. La primera visita a la landing no descarga el mapa, el chat ni el formulario.
04-monorepo.md
Estructura del workspace
Un workspace pnpm (pnpm-workspace.yaml) con aplicaciones en artifacts/ y librerías en lib/. Los nombres de paquete empiezan por @workspace/.
| Ruta | Paquete | Qué es |
|---|---|---|
artifacts/conciergo | @workspace/conciergo | La app React + Vite. Páginas en src/pages, layout en src/components/layout |
artifacts/api-server | @workspace/api-server | La API Express: rutas, middlewares de auth, Socket.io y el build con esbuild |
api/index.js | — | La función de Vercel: importa artifacts/api-server/dist/app.mjs |
lib/api-spec | @workspace/api-spec | openapi.yaml y la configuración de Orval |
lib/api-client-react | @workspace/api-client-react | Hooks de React Query generados, más custom-fetch.ts (base /api, token, errores) |
lib/api-zod | @workspace/api-zod | Esquemas Zod generados desde el mismo contrato |
lib/db | @workspace/db | Esquema Drizzle (src/schema/), el cliente db y drizzle.config.ts |
artifacts/mockup-sandbox | @workspace/mockup-sandbox | Sandbox de Replit para previsualizar componentes. No se despliega |
scripts | @workspace/scripts | hello.ts de ejemplo y post-merge.sh (instalar y aplicar el esquema tras un merge en Replit) |
attached_assets/ | — | Imágenes adjuntadas en Replit, con alias @assets en Vite; ninguna página las importa |
.agents/memory/ | — | Notas para agentes: decisiones del stack y sistema de diseño |
neon.ts | — | Configuración de Neon para el proyecto: Neon Auth activado |
docs/ | — | Esta documentación |
Comandos
| Comando | Qué hace |
|---|---|
pnpm install | Instala todo. Un preinstall aborta si se usa npm o yarn |
pnpm --filter @workspace/api-server run dev | Compila la API y la arranca en :8080 con Socket.io |
pnpm --filter @workspace/conciergo run dev | Vite en :5173, con /api redirigido a la API local |
pnpm run typecheck | tsc --build de las librerías y tsc --noEmit de cada app y de scripts |
pnpm run build | El typecheck y después el build de cada paquete que lo tenga |
pnpm --filter @workspace/api-spec run codegen | Regenera hooks y esquemas desde openapi.yaml y comprueba los tipos de las librerías |
pnpm --filter @workspace/db run push | Aplica el esquema Drizzle a la base de DATABASE_URL (drizzle-kit push) |
pnpm run build falla hoy porque el typecheck de la web da 8 errores (ver Testing · integración). Vercel no lo usa: su build llama directamente a los builds de la web y de la API.
Ajustes del workspace
- Catálogo de versiones. React, Vite, Tailwind, TanStack Query, Drizzle, Zod y el resto de dependencias compartidas se declaran una vez en
catalog:y los paquetes las piden con"catalog:". - Antigüedad mínima.
minimumReleaseAge: 1440impide instalar una versión publicada hace menos de un día, como defensa ante paquetes comprometidos.@neondatabase/*yjoseestán exentos. - Builds permitidos. Solo
esbuild,@swc/core,mswyunrs-resolverpueden ejecutar scripts de instalación, yesbuildestá fijado a 0.27.3 para todo el árbol. - Versiones de herramientas.
packageManager: pnpm@11.21.0,engines.node >= 22, TypeScript~5.9.3.
05-api.md
Superficie API
Base: /api. El contrato es lib/api-spec/openapi.yaml (OpenAPI 3.1, 15 operaciones); la implementación vive en artifacts/api-server/src/routes/.
Acceso: pública no mira el token; opcional lo usa si llega (para calcular isAttending); sesión responde 401 sin un JWT válido.
Salud
| Método | Ruta | Acceso | Respuesta |
|---|---|---|---|
| GET | /healthz | pública | { "status": "ok" } |
Usuarios
| Método | Ruta | Acceso | Qué hace |
|---|---|---|---|
| GET | /users/me | sesión | El perfil propio |
| PUT | /users/me | sesión | Cambia solo los campos enviados: name, bio, avatarUrl, musicGenres, city |
| GET | /users/:userId | sesión | El perfil de otra persona; 404 si no existe |
Eventos
| Método | Ruta | Acceso | Qué hace |
|---|---|---|---|
| GET | /events | opcional | Hasta 50 bolos por fecha descendente; ?genre= filtra por género exacto |
| POST | /events | sesión | Crea un bolo y apunta a quien lo crea. 201 |
| GET | /events/:eventId | opcional | Un bolo; 404 si no existe |
| DELETE | /events/:eventId | sesión | Borra un bolo propio (403 si es de otra persona). La interfaz no lo usa |
| POST | /events/:eventId/attend | sesión | Apunta y crea una conexión con cada otra persona apuntada |
| DELETE | /events/:eventId/attend | sesión | Borra la asistencia y las conexiones de ese bolo, con sus mensajes. 204 |
| GET | /events/stats | sesión | Totales de bolos, personas y bolos futuros, y los cinco géneros con más bolos. La interfaz no lo usa |
Cada bolo se devuelve con createdBy (el perfil de quien lo creó), attendeesCount e isAttending.
Conexiones y mensajes
| Método | Ruta | Acceso | Qué hace |
|---|---|---|---|
| GET | /matches | sesión | Tus conexiones, la más nueva primero, con otherUser, event, lastMessage y unreadCount |
| GET | /matches/:matchId | sesión | Una conexión; 404 si no existe y 403 si no es tuya |
| GET | /matches/:matchId/messages | sesión | El historial en orden cronológico; cada mensaje lleva senderAuthId |
| POST | /matches/:matchId/messages | sesión | Envía un mensaje (400 si está vacío). 201, y lo emite por Socket.io si hay servidor |
unreadCount cuenta todos los mensajes que te ha enviado la otra persona: no se guarda qué has leído.
Chat en vivo (Socket.io)
Ruta: /api/socket.io. Solo existe cuando la API corre como servidor (en local); en Vercel responde 404 (ver Arquitectura).
El cliente se conecta con { token, matchId } en el handshake. El servidor verifica el JWT, busca al usuario y comprueba que la conexión es suya; si algo falla, rechaza el socket con Invalid token, User not found o Forbidden. Después une el socket a la sala chat:<matchId>, donde se emite new_message con el mismo JSON que devuelve el POST.
La regla de las conexiones
POST /events/:eventId/attend guarda la asistencia y recorre al resto de asistentes. Cada pareja se guarda con el id menor primero, y la restricción única (event_id, user_id_1, user_id_2) hace que repetir la llamada no duplique nada:
for (const attendee of otherAttendees) {
const u1 = Math.min(user.id, attendee.userId);
const u2 = Math.max(user.id, attendee.userId);
await db
.insert(matchesTable)
.values({ eventId, userId1: u1, userId2: u2 })
.onConflictDoNothing();
}
Errores
Todas las rutas responden errores como { "error": "<mensaje>" }. Un error sin capturar lo recoge el último middleware de app.ts con un 500.
El contrato tipa el cliente, pero el servidor solo pasa por Zod la respuesta de /healthz: los cuerpos de POST y PUT se leen sin validar.
06-auth.md
Autenticación
Las cuentas son de Neon Auth (Better Auth gestionado por Neon). La app no guarda contraseñas ni sesiones propias: su tabla users enlaza cada cuenta por auth_id y guarda solo el perfil musical.
Del login al usuario de la base de datos
- Login.
/auth/sign-iny/auth/sign-uppintanAuthViewde@neondatabase/auth-uidentro deNeonAuthUIProvider: email y contraseña o Google, nombre obligatorio al registrarse y recuperación de contraseña. La sesión queda en una cookie de Neon Auth (credentials: "include"). - JWT. Antes de cada petición a la API,
getAccessToken()(src/lib/auth.ts) busca un JWT en este orden y se queda con el primero válido:- la cabecera
set-auth-jwtde la respuesta degetSession(); - el token de la sesión, si ya es un JWT;
authClient.token();GET <Neon Auth>/token.
- la cabecera
- Verificación. La API lee
Authorization: Bearery verifica el token conjosecontra<NEON_AUTH_BASE_URL>/.well-known/jwks.json, exigiendo que el emisor sea el origen de esa URL. El JWKS se descarga una vez y se reutiliza. - Alta en el primer uso.
provisionUser()busca la fila conauth_id = sub. Si no existe, la crea con el nombre y el avatar del token.
El nombre de una cuenta nueva
El token no siempre trae el nombre. profileFromPayload() prueba en este orden: el claim name, la parte del email antes de la @ y, si no hay nada, "Usuario". Cuando el resultado es "Usuario", provisionUser() consulta la tabla neon_auth."user", que Neon Auth mantiene en la misma base de datos, y toma de ahí nombre e imagen. Si una petición posterior trae un nombre mejor o un avatar que faltaba, actualiza la fila.
Invitados
GET /events y GET /events/:eventId usan optionalAuth: con un token válido identifican a la persona y calculan isAttending; sin él, o con uno inválido, siguen como invitado. El resto de rutas usan requireAuth.
En el front, las pantallas de invitado no muestran Conexiones ni Perfil, y los botones de apuntarse llevan a iniciar sesión.
El chat
El handshake de Socket.io lleva el mismo JWT y el id de la conexión. El servidor lo verifica igual que una petición HTTP y además comprueba que la persona es una de las dos de esa conexión antes de unirla a la sala.
Al cerrar sesión o cambiar de cuenta
authClient.signOut() cierra la sesión y la app vuelve a la landing. Si en la misma pestaña entra otra cuenta, AuthQueryClientCacheInvalidator vacía la caché de TanStack Query para que no queden datos de la anterior.
Configuración
| Variable | Dónde | Para |
|---|---|---|
NEON_AUTH_BASE_URL (o NEON_AUTH_URL) | API | JWKS y emisor esperado. Sin ella, las rutas protegidas responden 401 |
VITE_NEON_AUTH_URL | Front | El cliente de Neon Auth. Si falta, usa la URL del proyecto de producción escrita en el código |
Los logs de pino ocultan Authorization y las cookies de cada petición.
07-data-model.md
Modelo de datos
Neon Postgres con Drizzle ORM. El esquema está en lib/db/src/schema/, una tabla por fichero. Todas usan un serial como clave y un created_at con la hora de inserción.
users
| Columna | Tipo | Notas |
|---|---|---|
auth_id | text | Único. El sub de la cuenta de Neon Auth |
name | text | Obligatorio |
bio | text | |
avatar_url | text | Viene de Neon Auth al crear la fila |
music_genres | text[] | Obligatorio, vacío por defecto |
city | text |
events
| Columna | Tipo | Notas |
|---|---|---|
title | text | Obligatorio |
artist_name | text | Obligatorio |
venue_name | text | Obligatorio |
venue_address | text | |
date | timestamp | Obligatorio |
lat, lng | double precision | Obligatorios; los usa el mapa |
genre | text | |
image_url | text | |
created_by_user_id | integer | → users.id, borrado en cascada |
event_attendees
Quién va a qué bolo: event_id → events.id y user_id → users.id, los dos con borrado en cascada. Único por (event_id, user_id), así que apuntarse dos veces no duplica nada.
matches
Una conexión entre dos personas por un bolo: event_id, user_id_1 y user_id_2, con user_id_1 siempre menor que user_id_2. Único por (event_id, user_id_1, user_id_2). Las tres claves ajenas borran en cascada.
messages
match_id → matches.id, sender_id → users.id y content. Borrar una conexión borra su chat.
Cascadas
users ─┬─► events ─┬─► event_attendees
│ └─► matches ──► messages
├─► event_attendees
├─► matches
└─► messages
Borrar a una persona borra los bolos que creó y, con ellos, las asistencias, conexiones y mensajes de todos los que iban. Antes de borrar una cuenta que ha creado bolos hay que pasarlos a otra persona.
Fuera del esquema
neon_auth."user". La tabla de cuentas de Neon Auth, en la misma base de datos. La API solo la lee, para completar el nombre de una cuenta nueva (ver Autenticación).- Bolos iniciales. Los seis bolos de producción los creó una cuenta de sistema (
auth_id = 'system-conciergo') directamente en la base de datos; no hay script de semilla en el repo. Las notas de.agents/memorypiden no crear personas falsas: llenarían las conexiones de gente con la que nadie puede hablar.
Cómo se aplica el esquema
No hay migraciones versionadas. pnpm --filter @workspace/db run push ejecuta drizzle-kit push, que compara el esquema de lib/db con la base de DATABASE_URL y aplica la diferencia. Existe también push-force, que no pide confirmación.
Lo que devuelve la API
Las rutas no devuelven filas tal cual. Las convierten con userToJson, eventToJson, messageToJson y enrichMatch: fechas en ISO 8601, null explícito en los opcionales y campos calculados como attendeesCount, isAttending, otherUser, lastMessage o unreadCount.
08-deploy-vercel.md
Deploy en Vercel
Producción: conciergo-sigma.vercel.app. Un solo proyecto de Vercel sirve la web y la API desde el mismo dominio, así que no hace falta CORS entre ellas.
Qué hace Vercel con main
Todo está en vercel.json:
| Paso | Configuración |
|---|---|
| Cuándo | Solo los pushes a main despliegan; las previews de otras ramas están desactivadas (git.deploymentEnabled) |
| Instalar | pnpm install --frozen-lockfile |
| Compilar | pnpm --filter @workspace/conciergo run build && pnpm --filter @workspace/api-server run build |
| Estáticos | artifacts/conciergo/dist/public |
| API | api/index.js como función, con artifacts/api-server/dist/** incluido y hasta 30 segundos por petición |
| Rutas | /api/:path* → /api/index; cualquier otra ruta → /index.html, para que el router del cliente resuelva |
api/index.js importa la app la primera vez y la guarda para las siguientes peticiones de la misma instancia. Si la importación falla (por ejemplo, por un módulo que falta en el bundle), responde 500 con {"error":"API failed to start"} y el motivo, en lugar de un error genérico de Vercel.
El build de Vercel no ejecuta el typecheck, así que un error de tipos no bloquea el deploy.
Variables de entorno
| Variable | Lado | Para |
|---|---|---|
DATABASE_URL | API | La cadena de conexión de Neon, con pooling |
NEON_AUTH_BASE_URL | API | El JWKS y el emisor de los tokens (NEON_AUTH_URL también vale) |
VITE_NEON_AUTH_URL | Front | El cliente de Neon Auth. Tiene un valor por defecto en src/lib/auth.ts |
VITE_GOOGLE_MAPS_API_KEY | Front | La clave de Maps. Es pública por naturaleza: conviene restringirla por dominio en Google Cloud |
LOG_LEVEL | API | Opcional, info por defecto |
Las VITE_* se escriben en el JavaScript durante el build: cambiarlas exige un deploy nuevo.
La base de datos y Neon Auth son el proyecto Neon concert-connect-eu, en Frankfurt.
Lo que no hay en Vercel
- Socket.io. Una función no mantiene conexiones abiertas, así que el chat no llega en vivo en producción; se ve al refrescar (ver Arquitectura).
- Migraciones en el deploy. El esquema se aplica a mano con
drizzle-kit pushantes de desplegar código que lo necesite.
En local
.env.example lista las variables, pero nada carga un .env de la raíz: la API lee process.env tal cual, y Vite solo lee los .env de artifacts/conciergo. Lo más simple es exportarlas en cada terminal:
pnpm install
# terminal 1: DATABASE_URL y NEON_AUTH_BASE_URL exportadas
pnpm --filter @workspace/api-server run dev # API en :8080, con Socket.io
# terminal 2: VITE_NEON_AUTH_URL y VITE_GOOGLE_MAPS_API_KEY exportadas
pnpm --filter @workspace/conciergo run dev # Vite en :5173
Vite redirige /api a API_PROXY_TARGET (http://127.0.0.1:8080 por defecto). PORT y BASE_PATH cambian el puerto y la ruta base de la web. En local el chat sí es en vivo.
09-decisions.md
Decisiones
Apuntarse es el match
No hay un segundo feed para valorar personas. Si dos personas van al mismo bolo, quedan conectadas. Es menos incómodo que una app de citas y el chat empieza con un tema de conversación: el concierto.
Borrarse quita el chat
Al dejar de ir, se borran tus conexiones de ese bolo y sus mensajes. La regla es que solo chatea quien va; mantener conexiones de alguien que ya no asiste dejaría chats con gente que no estará allí. Como las rutas del chat comprueban que la conexión existe, no hace falta revisar la asistencia en cada mensaje.
El cartel es público
Eventos, mapa y fichas se ven sin cuenta (optionalAuth). La landing no es un muro: quien llega ve primero qué conciertos hay, y la cuenta se pide al apuntarse.
El contrato manda
openapi.yaml es la fuente de los tipos: Orval genera los hooks del front y los esquemas Zod, y las páginas solo llaman a la API a través de esos hooks. Cambiar una respuesta obliga a tocar el contrato, regenerar y compilar. El servidor aún no valida con Zod lo que recibe; hoy el contrato protege sobre todo al cliente.
Neon Auth en lugar de Clerk
La primera versión, en Replit, usaba Clerk. Al pasar a Vercel en agosto de 2026, el login pasó a Neon Auth: las cuentas viven en la misma base de datos que el producto y la API solo necesita el JWKS para verificar tokens. La fila de users se crea en el primer uso, sin webhook.
La app Express separada del servidor
app.ts exporta la app y index.ts la escucha con Socket.io. Así una función de Vercel puede importar la app sin abrir un puerto. El coste está asumido: en producción no hay chat en vivo.
Base de datos perezosa
El pool de Postgres se crea en la primera consulta. Una instancia nueva de la función que solo atiende /api/healthz arranca sin tocar la base de datos.
Instalaciones con un día de margen
minimumReleaseAge: 1440 hace que pnpm rechace versiones publicadas hace menos de 24 horas. La mayoría de paquetes maliciosos se retiran antes de ese plazo.
Solo main despliega
Las previews de Vercel están desactivadas. Solo hay una base de datos y un proyecto de Neon Auth, así que una preview trabajaría sobre los datos de producción.
10-challenges-results.md
Retos y resultados
Retos
Salir de Replit
El proyecto nació en Replit con Clerk. El 16 y 17 de agosto de 2026 pasó a Vercel y Neon Auth: la API se separó en una app importable para la función, el login se rehízo con @neondatabase/auth-ui y hubo que arreglar el envío del JWT para que apuntarse, las conexiones y el perfil funcionaran con la nueva sesión. Después se limitó el deploy a main para que las previews dejaran de publicarse.
Sacar un JWT de Neon Auth
La sesión de Neon Auth vive en una cookie, pero la API necesita un JWT en la cabecera. El SDK puede entregar ese token por varias vías, así que getAccessToken() prueba cuatro fuentes por orden (ver Autenticación) y la caché reintenta los 401 mientras el token llega.
Cuentas sin nombre
El JWT de una cuenta recién creada puede no traer el nombre, y las primeras conexiones salían como «Usuario». La API lee entonces la tabla neon_auth."user" y corrige el nombre en cuanto lo tiene.
Google Maps
La versión 2 de @googlemaps/js-api-loader quitó la clase Loader; ahora se usan setOptions({ key }) e importLibrary(). Los marcadores avanzados exigen un mapId (la app usa DEMO_MAP_ID), y sin las APIs activadas en Google Cloud el mapa enseña el aviso de Google en lugar de los bolos.
Conexiones que sobrevivían
Al principio, borrarse de un bolo dejaba vivas las conexiones y se podía seguir chateando. Ahora se borran con la asistencia y la cascada se lleva los mensajes.
Lo que falta
| Hueco | Efecto |
|---|---|
| Sin Socket.io en Vercel | En producción los mensajes llegan al refrescar, no al momento |
| Contador de mensajes | unreadCount cuenta todos los mensajes recibidos; no hay estado de leído, así que nunca baja |
| Bolos sin ubicación | El formulario envía lat: 0, lng: 0: un bolo creado desde la app no sale en el mapa de España |
| Sin validación en el servidor | Los cuerpos de POST y PUT no pasan por los esquemas Zod |
| Tipos rotos en la web | pnpm run typecheck da 8 errores en artifacts/conciergo (ver integración) |
| Sin tests automatizados | Ver Testing · visión |
| Endpoints sin interfaz | DELETE /events/:id y GET /events/stats existen pero ninguna pantalla los usa |
Resultados
- La app está en producción con seis bolos en Madrid y Barcelona, login con email o Google y el ciclo completo: ver el cartel, apuntarse, conectar y chatear.
- El workspace se lee en orden: contrato → código generado → API Express → páginas React. Un cambio en una respuesta se ve en el contrato antes que en el código.
- Las páginas se cargan por separado y las dependencias grandes van en trozos propios, así que la landing no descarga el mapa ni el chat.
11-angular-skill.md
Front · React
En los otros proyectos este documento repasa cómo se usa Angular. Conciergo es una app React 19 con Vite, así que aquí se cuentan las prácticas de su cliente (artifacts/conciergo).
Rutas
Wouter, con la ruta base de Vite (BASE_PATH) aplicada al router. Cada página es un lazy(); mientras carga, RouteFallback pinta el esqueleto del layout para que la navegación no parpadee. Una ruta desconocida cae en NotFound.
Datos
- Solo hooks generados. Las páginas no llaman a
fetch: usan los hooks de@workspace/api-client-react(useListEvents,useAttendEvent,useSendMessage…), tipados desde el contrato. - Un
QueryClientcon reglas propias: 15 segundos de frescura, refresco al volver a la ventana y al reconectar, tres reintentos para un 401 y ninguno para el resto de 4xx. - Invalidación tras escribir. Apuntarse o borrarse invalida eventos, fichas y conexiones de una vez con
invalidateAfterAttendance(). - Conexiones vivas.
useMatchesQuery()repite la petición cada 20 segundos con la sesión abierta y conserva los datos anteriores mientras llega la respuesta, para que el badge no parpadee.
Formularios
react-hook-form con zodResolver: el formulario de nuevo bolo valida en el cliente (campos obligatorios, URL de imagen válida) y enseña los errores junto a cada campo.
Interfaz
- Componentes shadcn/ui sobre Radix en
src/components/ui, con Tailwind 4 y una estética de cartel: bordes gruesos, sombras duras y tipografía serif en mayúsculas. - Framer Motion para la entrada de tarjetas, mensajes y bloques que aparecen al apuntarse.
AppLayoutcambia entre barra lateral (escritorio) y barra inferior (móvil), y oculta Conexiones y Perfil a los invitados.- La lista de eventos espera a que las imágenes de las tarjetas estén descargadas antes de quitar el esqueleto.
Carga
vite.config.ts separa React, TanStack Query y Framer Motion en trozos propios. En el build del 20 de octubre de 2026 pesan 186 kB, 36 kB y 127 kB sin comprimir (59 kB, 11 kB y 42 kB con gzip). El mapa solo carga la API de Google en /events/map.