# UghPass — plataforma de consumiciones para eventos

> Marca: UghPass ("¿Hacer fila? Ugh, paso."). Nombre y textos centralizados en `src/config/brand.ts` y `.env`.

El público escanea un QR, pide, paga con MercadoPago o en caja y retira por número.
La barra despacha desde un celular aunque se corte internet. El organizador se registra,
arma su evento y conecta su MercadoPago solo. La plataforma cobra su comisión automáticamente.

## Cómo fluye el dinero

```
Asistente paga $10.000 con MercadoPago
        │
        ├─► $9.700 → cuenta MP del ORGANIZADOR (menos la comisión propia de MP)
        └─►   $300 → cuenta MP de la PLATAFORMA (marketplace_fee, 3%)
                         └─ 20% de esos $300 queda registrado para quien refirió al organizador
```

- La plataforma nunca toca el dinero del organizador (MercadoPago Marketplace).
- El efectivo no paga comisión.
- Los referidos acumulan ganancias en un libro (`referral_earnings`). El pago al referido
  se hace por transferencia desde tu cuenta; el panel muestra cuánto se le debe a cada uno.

## Modo menores (egresados, eventos familiares)

Se activa al crear el evento o desde Resumen → Configuración.

- Los productos con alcohol desaparecen del menú y el servidor rechaza su venta (aunque se cargue un producto mal marcado).
- El menú, el pase, la pantalla de listos y el afiche muestran el sello «Evento sin alcohol».
- Pestaña **Cierre**: certificado imprimible y un link público para el colegio y las familias (sin importes).
  El link está firmado: no se puede adivinar el de otro evento.
- El certificado es "limpio" solo si: el modo rigió desde antes del primer pedido, nunca se desactivó y
  se vendieron 0 unidades con alcohol. Cada ítem vendido guarda si era alcohólico al momento de la venta.

## Menú: plantillas, edición y fotos

- **Plantillas** (Panel → evento → Menú): egresados, boliche +18, festival, club o kermés. Cargan categorías,
  productos con ícono y precios sugeridos; en eventos con menores se omite lo que tiene alcohol. Se editan en `src/lib/menu-templates.ts`.
- **Editar productos**: tocar cualquier producto abre la hoja de edición (nombre, precio, stock, categoría, barra, alcohol, ícono, foto).
- **Fotos**: el navegador las achica a 640 px (WebP) antes de subirlas. Se guardan en `product_images` y se sirven cacheadas.
- Precios: "1.500" = mil quinientos; "1.500,50" también se entiende.
- **Íconos**: set propio de 98 íconos (`public/iconos/*.svg`). Al escribir el nombre del producto se elige solo
  (`src/lib/icons.ts`); el organizador puede cambiarlo con el buscador. Para sumar íconos: editar `scripts/iconos/`
  y correr `node scripts/iconos/build.mjs` (regenera los SVG y `src/lib/icon-catalog.ts`).

## Operación del evento

- **Modo ensayo**: «Crear ensayo» copia el evento con pagos simulados (menú y QR de caja) para capacitar. «Borrar pedidos de prueba» lo deja limpio.
- **Duplicar evento**: menú con fotos, precios, estaciones (mismos PIN) y configuración.
- **Caja**: Cobrar · Buscar/devoluciones · Cierre de caja (arqueo: efectivo contado vs sistema).
- **Planillas** CSV (pedidos, productos, por hora) desde el resumen del evento.
- **Propina** opcional por evento (solo pagos digitales, sin comisión).
- **Aviso de listo**: Web Push + sonido. Requiere `VAPID_PUBLIC_KEY` y `VAPID_PRIVATE_KEY` (`npm run keys:generate -- push`).
- **Alertas** al administrador por mail cada 5 min si algo falla (MercadoPago inalcanzable, pagos sin confirmar, tokens vencidos). Requiere `SMTP_URL`.
- **Monitor externo**: `GET /api/salud` responde 200 si base y worker están bien, 503 si no. Configurarlo en UptimeRobot o similar (gratis) para enterarte si se cae el servidor entero.
- **Legales**: `/terminos` y `/privacidad` (borradores: revisar con un abogado). El registro exige aceptarlos.

## Marca blanca

Cada organización configura su marca en **Panel → Marca**: nombre, color y logo (PNG, JPG o WebP, hasta 300 KB).
Se aplica al menú, el pase, la pantalla de listos, el afiche, el certificado y el título y el ícono de la pestaña.
El color genera una paleta completa y el texto de los botones se elige solo para que se lea.

| Nivel | Qué ve el público | Quién lo activa |
| --- | --- | --- |
| Marca propia | Logo y color del organizador + «Funciona con UghPass» abajo | El organizador, solo |
| Marca blanca | Solo la marca del organizador, sin ninguna mención a UghPass | Vos, desde `/admin` |

Pendiente para una próxima versión: dominio propio por organización (por ejemplo `pedidos.colegio.edu.ar`).

## Panel de plataforma (`/admin`)

Acceso: usuarios marcados como administradores (`npm run admin:grant -- email`) o con su email en
`PLATFORM_ADMIN_EMAILS` del `.env` (separados por coma). Secciones:

- **Resumen y comisiones**: comisión por defecto (editable, sin reiniciar), organizaciones (comisión propia,
  marca blanca, suspender) y referidos.
- **Eventos**: comisión propia por evento (0 % para pruebas, agradecimientos, acuerdos). Prioridad:
  evento → organización → por defecto. El efectivo nunca paga comisión.
- **Soporte**: buscar cualquier pedido (número, nombre, email, ID de MercadoPago); verificar con MercadoPago,
  marcar pagado a mano (ventas en papel del plan C), cancelar y devolver.
- **Estado del sistema**: base, credenciales y conexión con MercadoPago, último aviso recibido, worker,
  claves, organizadores con token vencido. Revisarlo antes de cada evento.

### Anterior (sigue vigente)

Para vos, sin tocar la base: métricas de los últimos 30 días, organizaciones (comisión propia, marca blanca, suspender)
y saldos de referidos con «Marcar pagado». Suspender corta la venta en todos los eventos de esa organización al instante.

Para darte acceso: registrate en `/registro` y después

```bash
npm run admin:grant -- tu@email.com        # --quitar para revocar
```

A cualquier otro usuario, `/admin` le responde 404.

## Desarrollo local

Requisitos: Node 22, Docker.

```bash
cp .env.example .env
npm install
npm run keys:generate          # pegá las dos líneas en .env
docker compose -f docker-compose.dev.yml up -d
set -a; . ./.env; set +a       # carga las variables en la terminal
npm run db:migrate
npm run db:seed                # evento de prueba listo para usar
npm run dev                    # http://localhost:3000
```

Con el seed:

| Qué | Dónde | Acceso |
| --- | --- | --- |
| Panel del organizador | `/ingresar` | demo@demo.com / demo1234 |
| Menú del público | `/e/demo` | abierto |
| Pantalla de listos (TV) | `/pantalla/demo` | abierto |
| Barra | `/estacion/<id>` (lo imprime el seed) | PIN 1234 |
| Caja | `/estacion/<id>` | PIN 5678 |

En otra terminal, el worker (cancela pedidos impagos y limpia sesiones): `npm run worker`.

Para probar en el celular dentro de tu red: `npm run dev -- -H 0.0.0.0` y entrá a
`http://IP-DE-TU-PC:3000`. La cámara para escanear QR solo funciona en HTTPS o localhost;
en la red local la barra puede entregar por número.

## Requisitos del servidor

| Recurso | Mínimo para pilotos | Recomendado al crecer |
| --- | --- | --- |
| Sistema | Debian 12 | Debian 12 |
| CPU | 2 vCPU | 4 vCPU |
| RAM | 4 GB (o 2 GB + swap) | 8 GB |
| Disco | 40 GB SSD | 80 GB SSD |
| Red | IPv4 pública, puertos 22, 80 y 443 abiertos | Ídem |
| DNS | Registro A del dominio apuntando a la IP | Ídem |
| Software | Docker Engine + Compose (lo instala el script) | Ídem |
| Backups | Diario automático + copia fuera del servidor | Postgres gestionado con backups |

No hace falta instalar Node, PostgreSQL, Apache ni certbot en el servidor: todo corre en contenedores
y Caddy obtiene y renueva el certificado HTTPS solo.

## Despliegue en Debian 12

```bash
# 1. Subir el proyecto al servidor (sin node_modules ni .next) y entrar a la carpeta
bash scripts/server-setup.sh        # una sola vez: Docker, firewall, swap, backup diario

# 2. Configuración
cp .env.example .env.production
nano .env.production                # DOMAIN, APP_URL=https://DOMAIN, POSTGRES_PASSWORD, claves, MP

# 3. Levantar todo (base, migraciones, app, worker y HTTPS)
docker compose --env-file .env.production up -d --build

# Datos de prueba (opcional)
docker compose --env-file .env.production run --rm migrate npm run db:seed
```

Actualizar a una nueva versión: subir los archivos y repetir el paso 3. Las migraciones corren solas.

Escalar horizontalmente: `docker compose --env-file .env.production up -d --scale app=3`.
El tiempo real usa LISTEN/NOTIFY de PostgreSQL, así que funciona con varias instancias sin Redis.

## Alternativa: servidor con Apache y PM2 (sin Docker para la app)

Si el servidor ya tiene Apache con HTTPS (Let's Encrypt) y PM2:

```bash
# Base de datos (solo accesible desde el servidor)
docker compose -f docker-compose.dev.yml up -d

# Si hay otra app en el puerto 3000:  sudo ss -ltnp | grep ':3000'   y   pm2 delete NOMBRE
npm ci
set -a; . ./.env; set +a              # APP_URL=https://tu-dominio
npm run db:migrate
npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
cd .next/standalone && PORT=3000 HOSTNAME=127.0.0.1 NODE_ENV=production pm2 start server.js --name ughpass --update-env && cd ../..
pm2 start npm --name ughpass-worker -- run worker
pm2 save
```

Actualizar: subir los archivos, repetir desde `npm ci` y reiniciar con `pm2 restart ughpass ughpass-worker --update-env`.

**Escalar y reiniciar sin corte** (recomendado): `ecosystem.config.cjs` corre la app en modo cluster (una
instancia por CPU) y el worker aparte, y lee el `.env` solo:

```bash
pm2 start ecosystem.config.cjs && pm2 save        # primera vez (scripts/actualizar.sh lo hace solo)
pm2 reload ecosystem.config.cjs --update-env      # actualizar sin cortar
pm2 scale ughpass 4                               # más instancias
```

En el VirtualHost de Apache, antes de `ProxyPass /`:

```apache
RequestHeader set X-Forwarded-Proto "https"
SetEnvIf Request_URI "^/api/stream" no-gzip=1
ProxyPass        /api/stream/ http://127.0.0.1:3000/api/stream/ flushpackets=on timeout=3600
ProxyPassReverse /api/stream/ http://127.0.0.1:3000/api/stream/
ProxyPass        / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
```

`npm run dev` es solo para programar: en el servidor usá siempre la versión compilada.

## MercadoPago (una sola vez, como plataforma)

Modelo: **marketplace**. Cada organizador conecta su cuenta por OAuth; cada cobro se crea con SU token y lleva
`marketplace_fee`, así que el dinero va a su cuenta y tu comisión a la tuya, sin intervención.

| Dónde se cobra | Producto de MP | API |
| --- | --- | --- |
| Menú del público | Checkout Pro | Orders (`/v1/orders`, `type: online`), con respaldo automático a Preferences si falla |
| Caja | Código QR dinámico | Orders (`/v1/orders`, `type: qr`); sucursal y caja del organizador creadas solas |
| Caja | Efectivo | Sin MP, sin comisión |

1. Crear una aplicación en https://www.mercadopago.com.ar/developers/panel/app (modelo Marketplace).
2. Redirect URL de OAuth: `https://TU-DOMINIO/api/mp/callback`.
3. **Webhooks** (Tus integraciones › Webhooks › Configurar notificaciones › Modo productivo):
   URL `https://TU-DOMINIO/api/webhooks/mercadopago`, eventos **Order (Mercado Pago)** y **Pagos**. Copiar la clave secreta.
4. Completar `MP_CLIENT_ID`, `MP_CLIENT_SECRET`, `MP_WEBHOOK_SECRET` (y opcional `MP_CHECKOUT=orders|preferences`).

Cada organizador conecta su cuenta desde Panel → Cobros y, para el QR en caja, carga la dirección una sola vez
(MP la pide para crear la sucursal). El receptor de avisos identifica al organizador por la order guardada o por su
`user_id` de MP; siempre vuelve a consultar el estado a MP con el token del organizador, valida firma y monto, y es idempotente.

Pruebas sin dinero real: `MP_API_URL` apunta la app a un simulador (solo desarrollo).

## Arquitectura

| Pieza | Tecnología | Por qué |
| --- | --- | --- |
| App | Next.js 15 (App Router, Server Actions) | Un solo servicio, SSR rápido |
| Base | PostgreSQL 16 + Drizzle ORM | Migraciones versionadas en `drizzle/` |
| Tiempo real | Server-Sent Events + LISTEN/NOTIFY | Sin servicios extra; escala horizontal |
| Tareas | pg-boss (worker aparte) | Colas sobre Postgres, con reintentos |
| Offline | Service worker + cola local en la barra | La red del predio colapsa |
| Tickets | Firma Ed25519 | La barra valida sin internet; no se pueden falsificar |
| HTTPS | Caddy | Certificado automático |

Reglas del dominio:
- Importes en centavos enteros. Comisiones en puntos básicos (`300` = 3%).
- Número de pedido correlativo por evento, reservado con bloqueo de fila.
- Stock descontado atómicamente; pedidos impagos vencen (35 min MP, 6 h efectivo) y devuelven stock.
- Webhooks de MP idempotentes; acciones de barra idempotentes por id generado en el dispositivo.
- Eventos con menores (`alcohol_allowed = false`) no muestran ni venden productos con alcohol.

## Estructura

```
src/
  app/            páginas y API (panel, menú /e, ticket /t, estación, pantalla, webhooks)
  app/actions/    Server Actions (auth, panel, estación)
  db/             esquema, migración y seed
  lib/            dominio: pedidos, pagos MP, tickets, comisiones, tiempo real, sesiones
  worker/         tareas de fondo
drizzle/          migraciones SQL generadas
scripts/          claves, setup del servidor, backups
```

Cambiar el esquema: editar `src/db/schema.ts` → `npm run db:generate` → `npm run db:migrate`.

## Novedades: pantalla con vida y cobro de referidos (migración 0009)

- **Opiniones** (panel → evento → pestaña *Opiniones*): todas las caritas y comentarios, con el reparto por carita.
- **Pantalla de pedidos** (`/pantalla/<slug>`):
  - una carita positiva (🙂 o 😍) sube flotando con el número del pedido, sin aprobación;
  - los comentarios positivos y los **saludos** del público aparecen solo si el organizador los aprueba (*Mostrar* / *Quitar de la pantalla*), y rotan cada 9 s;
  - un pedido listo se anuncia en grande unos segundos.
- **Saludos**: con el pedido pagado, el pase muestra «💬 Mandá un saludo a la pantalla» (uno por pedido, 120 caracteres, sin links). Se apagan por evento.
- **Referidos sin saldo**: lo ganado queda *en curso* hasta que el evento termina (cerrado o 12 h después del fin) y ahí pasa a *disponible*. Desde el mínimo, el referidor pide el cobro con CBU/CVU/alias; la plataforma transfiere dentro de los días configurados y lo marca en `/admin/referidos`.
- **Ícono del evento en el celular**: ahora se genera un PNG cuadrado (192 y 512) con el logo. Antes se declaraba el logo WebP como PNG y Android cerraba la app instalada.

## Pantalla viva: plantillas, QR, emojis y mensajes (migración 0010)

- **Dos modos** por link: `/pantalla/<slug>` (barra: números listos grandes) y `/pantalla/<slug>?modo=escenario` (pantallas laterales: QR gigante, saludos y reacciones; números en una tira).
- **QR al menú** siempre visible; "respira" cuando la pantalla está tranquila. Sin actividad y sin números listos, se muestran los productos más pedidos (sin alcohol si el evento es con menores o pasó el corte).
- **Emojis desde el menú y el pase**: 4 botones según la plantilla. Límite: 8 cada 10 s por celular y 6 por segundo en la pantalla. Con 12 en 8 s aparece una "explosión" en grande.
- **Mensaje del organizador** (5 min, 15 min, 1 h o fijo): franja destacada en todas las pantallas al instante.
- **Plantillas**: Clásica, Peña y folclore, Carnaval, Egresados, Cumpleaños, Boliche, Familiar. Cambian fondo, partículas, emojis y frases; nunca el tamaño ni el lugar de los números. Se prueban sin guardar con `?tema=<clave>`.
- Todo se maneja en panel → evento → **Pantalla y opiniones**.
- **QR de la caja**: cada llamada a MercadoPago corta a los 15 s y el error dice en qué paso falló (sucursal, caja o QR) con el detalle de MP; se loguea `[qr] falló en …` en `pm2 logs`.

## En vivo: encuestas, cuenta regresiva, saludos libres, efectos y logo del evento (migración 0011)

- **QR de la caja arreglado**: MercadoPago exige `external_code` de hasta 30 caracteres y, en QR, `unit_measure`. (También hacía que el Checkout cayera siempre a Preferences; ahora usa Orders.)
- **QR de las pantallas quieto** y siempre por encima de cualquier efecto.
- **Momentos** (panel → Pantalla y opiniones): lluvia de emojis, confeti, aplausos, ola de luz. Si la pantalla está tranquila, cada 9 a 14 min se dispara una sola.
- **Mensaje del organizador** con emojis a un toque.
- **Cuenta regresiva** («Sale la banda en» + hora): grande en ambas pantallas; a los 2 min de llegar a cero se va.
- **Espera estimada** (mediana pago→listo de los últimos 30 min) en la pantalla de barra y en el pase.
- **Encuestas**: votación con 2 a 6 opciones, o «¿De dónde venís?» que solo acepta una localidad (letras, sin links; une tildes y mayúsculas). En pantalla solo salen localidades con 2+ votos y el organizador puede ocultar cualquiera. Un voto por celular (cookie).
- **Saludos libres** por 15/30/60 min, sin compra: uno por minuto por celular y siempre con aprobación («Mostrar todos» para aprobar en tanda).
- **Logo propio del evento** (además del de la organización): menú, pantalla («organiza …») e ícono del celular.
- **Primero las ventas**: lo social sale de una caché en memoria (pocas consultas aunque haya miles de celulares) y se pausa solo cuando la cola supera `LIVE_BUSY_QUEUE`.

## Estaciones sin re-PIN, memoria del evento y ajustes de pantalla (migración 0012)

- **PIN de caja/barra**: antes había UNA cookie para todas las estaciones; abrir la barra en el mismo navegador cerraba la caja (dejaba de actualizarse y al recargar pedía PIN). Ahora hay una cookie por estación (`st_<id>`), dura 7 días y se renueva sola cada vez que la estación se abre.
- **«¡Listo!» siempre visible**: va por encima de todo; en escenario aparece del lado de los saludos para no tapar el QR.
- **QR de escenario** con tamaño según el alto de la pantalla (probado 1280×720, 1336×741, 1920×1080).
- **Memoria del evento** (pestaña *Memoria*): línea de tiempo con mensajes, cuentas regresivas, efectos, plantillas, saludos (aprobados o no), opiniones, encuestas con resultado final y conteo de reacciones. Se descarga en planilla e imprime. Las reacciones se suman en memoria y se guardan cada 5 s.

## Minijuegos (migración 0013)

- **Atrapá** (mover la canasta, bombas restan) y **Penales** (5 tiros contra el arquero). Pensados para el retraso de la red: pasos claros, sin reflejos finos.
- El organizador elige el juego, si hace falta **pedido pagado** (2 turnos por pedido; sin pedido: 1 turno cada 5 min por celular) y por cuánto tiempo (10/15/30 min o hasta cortarlo). Puede **cortar** o **saltar el turno** en cualquier momento.
- En el menú y el pase aparece **🎮 ¡Quiero jugar!**: el primero juega, el resto hace fila y vibra cuando le toca. El control mantiene la pantalla del celular encendida.
- Se dibuja en la pantalla de **escenario**, en el lugar de los saludos: el QR y la tira de números listos siguen siempre a la vista. La pantalla de barra solo avisa quién juega.
- El servidor decide turnos y tiempos; todas las pantallas ven la misma partida (semilla por turno); la primera que manda el puntaje lo registra.
- **Primero las ventas**: con la cola por encima de `LIVE_BUSY_QUEUE` no arrancan turnos nuevos.
- Ranking de las últimas 12 h en pantalla; cada jugada queda en la **Memoria**.
- Las calificaciones (caritas) ahora flotan por delante de todo, del lado opuesto al QR.
