From 13adcc25b64998d17a15141fd0773a1d47e35c3a Mon Sep 17 00:00:00 2001 From: Diego Date: Fri, 14 Aug 2026 06:47:24 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(P12.1):=20API=20m=C3=B3vil=20para=20so?= =?UTF-8?q?cios=20con=20v=C3=ADnculo=20auth.users=20=E2=86=94=20socios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints bajo /api/mobile/v1/* para que un socio autenticado consulte SUS PROPIOS datos desde una app: perfil, cuotas sociales (pagas/impagas + resumen de deuda), compras y —si es titular— las cuotas del grupo familiar. Son las primeras rutas HTTP del repo; hasta ahora todo el acceso a datos vivía en Server Actions. El problema no era exponer los datos sino aislarlos: el RBAC es todo-o-nada por módulo, y `select_cuotas` está gateada en el mismo permiso `socios:leer` que da lectura del padrón entero. Además no existía ningún vínculo entre auth.users y socios. La solución es que el socio tenga cero permisos de tabla. No se agrega ninguna política RLS nueva sobre socios/cuotas/ventas: toda lectura pasa por funciones SECURITY DEFINER que derivan el socio de auth.uid() y no aceptan ningún identificador de socio como parámetro, así que no hay IDOR posible. Verificado en psql: con el JWT de un socio, socios/cuotas/ventas devuelven 0 filas. Dos triggers hacen que un socio nunca sea staff. El segundo es el que se olvida: sin él, updateUsuarioRole() le asignaría Administrador a la cuenta móvil de un socio desde la pantalla de Seguridad. Alta por código de invitación de un solo uso emitido por el club (el padrón no tiene emails, y migrate.py sintetizó los DNI faltantes como dni = nro_socio, así que DNI + nro_socio no prueba identidad). Crockford base32 de 10 chars; en la base vive sólo sha256(codigo || PEPPER) calculado en Node, así que un dump no alcanza para fuerza bruta offline. El un-solo-uso es un UPDATE ... WHERE usado_at IS NULL ... RETURNING en una sola sentencia. El grupo familiar falla cerrado: sólo el titular, y sin titular designado no lo ve nadie — inferirlo sería inventar una regla de autorización cuyo costo de error es mostrar la deuda de un tercero. /socios/grupos-familiares avisa cuántos grupos están así para poder corregirlos. El middleware redirigía /api a /login con un 307, devolviendo HTML a un cliente que espera JSON. Se corrige en el matcher y con un guard en updateSession. Rate limit en tabla (Vercel corre lambdas sin estado compartido) por IP de x-vercel-forwarded-for; sin cabecera confiable degrada a por-código en vez de usar un bucket global, que permitiría bloquear todas las activaciones. Seguridad → Usuarios excluye las cuentas de socios y pagina hasta agotar: cada activación crea un usuario en Auth (hasta ~8.400) y el staff se caía del listado. Docs en docs/API_MOBILE.md. Requiere INVITACIONES_PEPPER en el entorno, enable_signup=false en el Dashboard del proyecto cloud, y SMTP configurado para que un socio pueda recuperar su contraseña. --- .env.example | 8 + CHANGELOG.md | 11 + PROGRESS.md | 8 + docs/API_MOBILE.md | 275 ++++ package-lock.json | 7 + package.json | 1 + .../(dashboard)/security/usuarios/actions.ts | 35 +- .../(dashboard)/socios/app-movil/actions.ts | 287 +++++ .../socios/app-movil/emision-masiva/page.tsx | 228 ++++ src/app/(dashboard)/socios/app-movil/page.tsx | 322 +++++ .../socios/grupos-familiares/actions.ts | 70 +- .../socios/grupos-familiares/page.tsx | 43 +- .../v1/auth/canjear-invitacion/route.ts | 217 ++++ .../v1/auth/validar-invitacion/route.ts | 89 ++ .../api/mobile/v1/mi/compras/[id]/route.ts | 36 + src/app/api/mobile/v1/mi/compras/route.ts | 48 + .../api/mobile/v1/mi/cuotas/resumen/route.ts | 26 + src/app/api/mobile/v1/mi/cuotas/route.ts | 38 + .../v1/mi/grupo-familiar/cuotas/route.ts | 45 + .../api/mobile/v1/mi/grupo-familiar/route.ts | 25 + src/app/api/mobile/v1/mi/perfil/route.ts | 26 + src/components/socios/CodigoEmitidoDialog.tsx | 98 ++ src/lib/api/mobile-auth.ts | 132 ++ src/lib/api/paginacion.ts | 71 + src/lib/api/rate-limit.ts | 94 ++ src/lib/api/response.ts | 65 + src/lib/api/rpc-errors.ts | 85 ++ src/lib/invitaciones.ts | 143 +++ src/lib/nav-config.ts | 1 + src/lib/schemas/mobile.ts | 71 + src/lib/supabase/admin.ts | 4 + src/lib/supabase/bearer.ts | 27 + src/lib/supabase/middleware.ts | 13 + src/middleware.ts | 4 +- src/types/app-movil.ts | 52 + supabase/config.toml | 10 +- .../20260813000001_app_movil_socios.sql | 1143 +++++++++++++++++ 37 files changed, 3826 insertions(+), 32 deletions(-) create mode 100644 docs/API_MOBILE.md create mode 100644 src/app/(dashboard)/socios/app-movil/actions.ts create mode 100644 src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx create mode 100644 src/app/(dashboard)/socios/app-movil/page.tsx create mode 100644 src/app/api/mobile/v1/auth/canjear-invitacion/route.ts create mode 100644 src/app/api/mobile/v1/auth/validar-invitacion/route.ts create mode 100644 src/app/api/mobile/v1/mi/compras/[id]/route.ts create mode 100644 src/app/api/mobile/v1/mi/compras/route.ts create mode 100644 src/app/api/mobile/v1/mi/cuotas/resumen/route.ts create mode 100644 src/app/api/mobile/v1/mi/cuotas/route.ts create mode 100644 src/app/api/mobile/v1/mi/grupo-familiar/cuotas/route.ts create mode 100644 src/app/api/mobile/v1/mi/grupo-familiar/route.ts create mode 100644 src/app/api/mobile/v1/mi/perfil/route.ts create mode 100644 src/components/socios/CodigoEmitidoDialog.tsx create mode 100644 src/lib/api/mobile-auth.ts create mode 100644 src/lib/api/paginacion.ts create mode 100644 src/lib/api/rate-limit.ts create mode 100644 src/lib/api/response.ts create mode 100644 src/lib/api/rpc-errors.ts create mode 100644 src/lib/invitaciones.ts create mode 100644 src/lib/schemas/mobile.ts create mode 100644 src/lib/supabase/bearer.ts create mode 100644 src/types/app-movil.ts create mode 100644 supabase/migrations/20260813000001_app_movil_socios.sql diff --git a/.env.example b/.env.example index 4dbec89..23eb543 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,11 @@ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + +# Pepper de los códigos de invitación de la app móvil (>= 32 caracteres). +# Se concatena al código antes de hashearlo, así que NUNCA vive en la base: +# un dump de Postgres no alcanza para hacer fuerza bruta offline sobre los +# hashes. Generar con: openssl rand -base64 48 +# ATENCIÓN: rotarlo invalida TODOS los códigos pendientes de canje de golpe. +# Nunca ponerle el prefijo NEXT_PUBLIC_ (quedaría expuesto en el bundle). +INVITACIONES_PEPPER=generar-con-openssl-rand-base64-48 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c88f0b..5f88bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ Remediación del export de advisories de Sentinello del 2026-08-04 (41 hallazgos ### Added +- **API móvil para socios** (P12.1, migración `20260813000001`). Endpoints bajo `/api/mobile/v1/*` para que un socio autenticado consulte **sus propios** datos desde una app en el teléfono: perfil, cuotas sociales (pagas/impagas + resumen de deuda), compras y —si es titular— las cuotas de su grupo familiar. Son las **primeras rutas HTTP del repo**: hasta ahora todo el acceso a datos vivía en Server Actions. + - **El problema no era exponer los datos, era aislarlos.** El RBAC del ERP es todo-o-nada por módulo: `select_cuotas` está gateada en `get_user_modulo_permission('socios','leer')`, exactamente el mismo permiso que da lectura del **padrón entero**. Darle un rol a un socio para que vea su deuda le mostraría la de los otros 8.399. Y no existía ningún vínculo entre `auth.users` y `socios` — la tabla no tiene email, ni teléfono, ni `user_id`. + - **La solución es que el socio tenga cero permisos de tabla.** No se agregó **ninguna** política RLS nueva sobre `socios`/`cuotas`/`ventas`: las políticas se OR-ean entre sí y cada permisiva nueva obliga a re-verificar que no amplíe el acceso de otro. En su lugar, toda lectura pasa por una función `SECURITY DEFINER` que deriva el socio de `auth.uid()` y **no acepta ningún identificador de socio como parámetro** — sin parámetro no hay IDOR que explotar. Como la cuenta del socio no tiene filas en `usuarios_roles`, su JWT contra PostgREST directo devuelve `[]` (verificado en psql sobre `socios`, `cuotas`, `ventas` y `categorias_sociales`). Auditar la seguridad de la API es leer esas ~16 funciones, y nada más. + - **Dos triggers hacen que un socio no pueda ser staff.** Es la mitigación del peor caso. `trg_socios_usuarios_excluye_staff` impide vincular una cuenta que ya tenga rol del ERP; `trg_usuarios_roles_excluye_socios` es el espejo, y es el que se olvida: sin él, `updateUsuarioRole()` en la pantalla de Seguridad le asignaría "Administrador" a la cuenta móvil de un socio sin que nada lo frene. No es una convención escrita en un README, es un `EXCEPTION`. + - **Alta por código de invitación, no auto-registro.** El padrón no tiene emails a los que mandar nada, y DNI + nro_socio no sirve como prueba de identidad: `migrate.py` sintetizó los DNIs faltantes como `dni = nro_socio`, así que en esas filas son el mismo dato. El club emite un código desde `/socios/app-movil` (individual o masivo, con export a Excel) y lo entrega en el mostrador. Formato Crockford base32 de 10 caracteres (2^50), con el mapeo `O→0`/`I→1`/`L→1` al canjear para que un error de tipeo no sea un código inválido. En la base vive **sólo** `sha256(codigo || INVITACIONES_PEPPER)`, calculado en Node: el pepper nunca toca Postgres, así que un dump no alcanza para fuerza bruta offline. No se usa bcrypt/argon2 a propósito — el código es un token de CSPRNG, no una clave humana, y un KDF lento sólo agregaría latencia al canje legítimo. + - **El "un solo uso" es un `UPDATE ... WHERE usado_at IS NULL ... RETURNING`**, una sola sentencia y no un SELECT-después-UPDATE. Con dos canjes concurrentes del mismo código el segundo espera el lock de fila, reevalúa la condición al soltarlo y no matchea — verificado con dos sesiones psql simultáneas. El `INSERT` del vínculo va en la misma transacción, así que si el socio ya estaba vinculado el índice parcial único rebota y el rollback deshace también el consumo del código. El canje **crea** la cuenta (lo que permite dejar `enable_signup = false`), y si el vínculo falla después de crear el usuario Auth, el usuario se borra por compensación. + - **El grupo familiar falla cerrado.** Sólo el titular (`grupos_familiares.titular_id`) ve las cuotas del grupo; si el grupo no tiene titular designado, no lo ve nadie. En los datos migrados puede haber grupos sin titular y es tentador inferirlo (el más antiguo, el de menor `nro_socio`), pero eso sería inventar una regla de autorización cuyo costo de error es mostrarle a alguien la deuda de un tercero. Para que no se vuelva un ticket irresoluble, `/socios/grupos-familiares` ahora avisa cuántos grupos están así y permite filtrarlos. De los demás miembros se expone sólo nombre, categoría y deuda — nunca DNI, fecha de nacimiento ni localidad. + - **El middleware redirigía `/api` a `/login` con un 307.** El matcher no excluía `/api` y el guard de sesión es incondicional salvo para `/login`, así que cualquier endpoint le habría devuelto el HTML del login a un cliente que espera JSON — indistinguible de un bug del endpoint. Se arregla en dos lugares a propósito: la exclusión en el matcher (que además ahorra un round-trip a GoTrue por request) y un guard al inicio de `updateSession`, que sobrevive a que alguien edite el matcher sin acordarse de por qué estaba así. + - **Rate limit en tabla, no en memoria.** Vercel corre lambdas sin estado compartido: un contador de módulo arrancaría vacío en cada invocación. 10 intentos por IP cada 15 minutos, 1 hora de castigo. La IP se toma de `x-vercel-forwarded-for` y **no** de `x-forwarded-for` a secas, que el cliente puede anteponer para rotar identidad en cada request y volver el limiter decorativo. Las RPCs de canje, validación y rate-limit están revocadas de `anon` **y** de `authenticated`, así que el único camino es el route handler y el limiter no se puede saltear yendo directo a PostgREST. + - Los handlers **nunca** propagan mensajes crudos de Postgres (el patrón `throw new Error(error.message)` de los ~48 `actions.ts`): las RPCs levantan identificadores snake_case que son contrato de API y los traduce `src/lib/api/rpc-errors.ts`; lo no mapeado es un 500 genérico con `X-Request-Id` para correlacionar en los logs. `admin.ts` gana `import "server-only"`. Documentación completa en `docs/API_MOBILE.md`. + - **Precio diferenciado para socios y no socios en los ítems de venta** (migración `20260812000001`). `items_ventas` pasa de un precio a dos: `precio` (que ahora significa explícitamente *tarifa de socio*) y `precio_no_socio`. El toggle **Socio | No Socio** que ya existía en el POS pasa de sólo cambiar qué datos del comprador se piden a **determinar cuánto se cobra**. - **El legacy ya tenía las dos tarifas y la migración original las perdió.** `ItemsVentas` en `docs/backup.sql` tiene `ValorSocio` **y** `ValorNoSocio`, y `migration/migrate.py` importaba sólo la primera. Por eso el backfill no aplica un porcentaje parejo: recupera el `ValorNoSocio` real de los 210 ítems legacy, matcheando por **primary key** —el uuid5 determinista que asignó el importador— y no por nombre, que en `items_ventas` no es único. De esos 210, 190 tenían ambas tarifas iguales y 20 diferían, con ratios que van de 1.17x a **4.0x** (*Alquiler Quincho Cerrado*: $48.000 socio / $192.000 no socio). Un `precio * 1.2` uniforme habría inventado 20 precios y, peor, habría dejado en **$0 para el no socio** a los 15 ítems que el club cobra gratis al socio y caro al resto (*Derecho de línea Galería*: $0 / $28.000; *Idoneidad de tiro*: $0 / $46.000) — justo los casos donde la tarifa de no socio es la única que importa. El `+20%` queda sólo como default de lo que se cargue de ahora en más. - **La tarifa la elige el servidor, no el browser.** `registrar_venta` ya resolvía el precio desde `items_ventas` (el cliente sólo manda `{item_id, cantidad}`), así que el `CASE` vive ahí: `p_socio_id IS NOT NULL` → `precio`, cualquier otro caso → `precio_no_socio`. Un `cliente` del histórico cuenta como no socio: la tabla `Clientes` del legacy era la de compradores sueltos. El `CASE` va en un `CROSS JOIN LATERAL` para no repetirlo en `precio_unitario` y en `subtotal`, que es la forma clásica en que estas dos copias se desincronizan. La función se redefine con `CREATE OR REPLACE` manteniendo la firma de 8 argumentos: sin `DROP` sobreviven los `GRANT` y PostgREST no queda con dos overloads. diff --git a/PROGRESS.md b/PROGRESS.md index 4d8d2f8..61f94d8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -149,6 +149,14 @@ --- +## FASE 12 — App móvil para socios + +| ID | Tarea | Estado | Fecha | +|----|-------|--------|-------| +| P12.1 | API `/api/mobile/v1/*` (perfil, cuotas, compras, grupo familiar) + vínculo `auth.users ↔ socios` con códigos de invitación y pantalla de emisión en el ERP | ✅ | 2026-08-13 | + +--- + ## Bloqueadores activos _Ninguno por ahora._ diff --git a/docs/API_MOBILE.md b/docs/API_MOBILE.md new file mode 100644 index 0000000..0ed0a04 --- /dev/null +++ b/docs/API_MOBILE.md @@ -0,0 +1,275 @@ +# API móvil para socios + +API HTTP que permite a la app de los socios consultar **sus propios** datos: +perfil, cuotas sociales, compras y —si es titular— las cuotas de su grupo familiar. + +Base: `https:///api/mobile/v1` + +--- + +## Modelo de seguridad + +La propiedad central, y la que hay que preservar en cualquier cambio futuro: + +> **Un socio autenticado tiene cero permisos de tabla.** Su JWT contra +> PostgREST directo (`/rest/v1/socios`, `/rest/v1/cuotas`, …) devuelve `[]`. +> Lo único que puede hacer es ejecutar las funciones `mobile_*` que tienen +> `GRANT EXECUTE ... TO authenticated`. + +Eso funciona porque el RBAC del ERP se resuelve por filas en `usuarios_roles`, y +una cuenta de socio **no tiene ninguna**. Las políticas RLS de `socios`, `cuotas` +y `ventas` piden `get_user_modulo_permission('socios'|'ventas', 'leer')`, que +para esa cuenta es `false`. + +Consecuencias de diseño: + +- **No se agregó ninguna política RLS nueva** sobre `socios`/`cuotas`/`ventas`. + Las políticas se OR-ean entre sí; una permisiva nueva obligaría a re-verificar + que no amplíe el acceso de nadie más. +- **Ninguna función acepta un `socio_id` por parámetro.** Todas lo derivan de + `auth.uid()` vía `mobile_socio_actual()`. Sin parámetro no hay IDOR. +- **Auditar la seguridad de esta API = leer las funciones** de + `supabase/migrations/20260813000001_app_movil_socios.sql`. No hay más superficie. + +### El invariante socio ≠ staff + +Si una cuenta móvil llegara a tener un rol del ERP, dejaría de ver "sólo lo suyo" +y pasaría a leer el padrón completo. Dos triggers lo hacen imposible a nivel de base: + +| Trigger | Sobre | Impide | +|---|---|---| +| `trg_socios_usuarios_excluye_staff` | `socios_usuarios` | vincular como socio una cuenta que ya tiene rol del ERP | +| `trg_usuarios_roles_excluye_socios` | `usuarios_roles` | asignarle un rol del ERP a una cuenta vinculada a un socio | + +El segundo es el que se olvida: sin él, la pantalla de Seguridad del ERP le +asignaría "Administrador" a la cuenta móvil de un socio sin que nada lo frene. + +--- + +## Autenticación + +La app usa **supabase-js directo** contra Supabase Auth para login y refresh +(`signInWithPassword`, rotación automática del refresh token). Esta API no +implementa `/login` ni `/refresh` a propósito: sería un lugar más por donde pasa +una contraseña, y una reimplementación del refresh. + +Todos los endpoints bajo `/mi/*` requieren: + +``` +Authorization: Bearer +``` + +| Situación | Respuesta | +|---|---| +| Sin header o mal formado | `401 no_autenticado` + `WWW-Authenticate: Bearer` | +| Token inválido o vencido | `401 no_autenticado` | +| Token válido, cuenta sin vincular | `403 cuenta_no_vinculada` | +| Vínculo revocado (desvinculado por el club) | `403 cuenta_no_vinculada` | + +El token se valida en la misma llamada que resuelve la identidad +(`mobile_contexto_socio`): PostgREST verifica la firma antes de poblar +`auth.uid()`, así que no hace falta un round-trip extra a GoTrue. + +--- + +## Envelope + +Éxito: +```json +{ "data": { ... } } +{ "data": [ ... ], "meta": { "page": 1, "per_page": 50, "total": 137 } } +``` + +Error: +```json +{ "error": { "code": "no_es_titular", "message": "Sólo el titular..." }, + "request_id": "b3f1…" } +``` + +Toda respuesta lleva `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` +y `X-Request-Id`. **Nunca se propaga un mensaje crudo de Postgres**: lo no +mapeado es un `500 error_interno` y el detalle real va a los logs de Vercel +junto al `request_id`. + +> **Contrato:** las funciones SQL levantan identificadores snake_case +> (`RAISE EXCEPTION 'no_es_titular'`), no frases en español. Son contrato de API +> y los mapea `src/lib/api/rpc-errors.ts`. Si se "mejora" el mensaje en la +> migración, el mapeo se rompe en silencio y todo pasa a ser 500. + +--- + +## Endpoints + +### Activación de la cuenta + +| Método | Ruta | Descripción | +|---|---|---| +| `POST` | `/auth/validar-invitacion` | Valida un código **sin consumirlo** | +| `POST` | `/auth/canjear-invitacion` | Canjea el código, **crea** la cuenta y devuelve la sesión | + +**`POST /auth/validar-invitacion`** — `{ "codigo": "K7M2P-QX9RT" }` + +```json +{ "data": { "socio": { "nro_socio": 1234, "apellido": "PÉREZ", "nombre": "Juan" } } } +``` + +Permite mostrar "¿Sos JUAN PÉREZ, socio 1234?" antes de pedir email y contraseña. + +| Código | Status | +|---|---| +| `codigo_invalido` | 400 | +| `codigo_ya_utilizado` | 409 | +| `codigo_expirado` | 410 | +| `demasiados_intentos` | 429 + `Retry-After` | + +**`POST /auth/canjear-invitacion`** — `{ "codigo", "email", "password" }` → `201` + +```json +{ "data": { "socio": {...}, + "session": { "access_token": "...", "refresh_token": "...", + "expires_at": 1234567890, "token_type": "bearer" } } } +``` + +El orden de las operaciones es parte del diseño: rate limit → validar sin +consumir → crear el usuario → canjear+vincular (atómico) → login. Validar +**antes** de crear evita consumir el código si la creación falla, y evita usar +el endpoint como oráculo de "¿este email ya tiene cuenta?" sin haber demostrado +tener un código. Si el canje falla después de crear el usuario, el usuario se +borra (compensación). + +Extra: `email_en_uso` → 409. + +### Datos del socio + +| Método | Ruta | Notas | +|---|---|---| +| `GET` | `/mi/perfil` | Categoría, alta, antigüedad (calculada), activo, email | +| `GET` | `/mi/cuotas` | `?estado=todas\|impagas\|pagas&desde=&hasta=&page=&per_page=` | +| `GET` | `/mi/cuotas/resumen` | Cuántas debe, cuánto suma, último período pagado | +| `GET` | `/mi/compras` | `?desde=&hasta=&page=&per_page=` — excluye anuladas | +| `GET` | `/mi/compras/{id}` | Cabecera + ítems con precios congelados | +| `GET` | `/mi/grupo-familiar` | **Sólo el titular** | +| `GET` | `/mi/grupo-familiar/cuotas` | **Sólo el titular** | + +`per_page` se clampea a 100 en el schema Zod **y** en SQL (`least(greatest(...))`), +porque las funciones son alcanzables por PostgREST directo sin pasar por el handler. + +`GET /mi/compras/{id}` devuelve **404** tanto si la venta no existe como si es de +otro socio. Nunca 403: un 403 confirmaría que el uuid existe y es de otra persona. + +### Grupo familiar + +Regla: **sólo el titular** (`grupos_familiares.titular_id`) ve las cuotas del grupo. + +| Situación | Código | Status | +|---|---|---| +| El socio no pertenece a un grupo | `sin_grupo_familiar` | 403 | +| El grupo no tiene titular designado | `grupo_sin_titular` | 403 | +| El socio es miembro pero no titular | `no_es_titular` | 403 | + +`titular_id IS NULL` **deniega**, no infiere. En los datos migrados del legacy +puede haber grupos sin titular, y adivinarlo (el más antiguo, el de menor +`nro_socio`) sería inventar una regla de autorización cuyo costo de error es +mostrarle a alguien la deuda de un tercero. + +Para que eso no sea un ticket irresoluble, `/socios/grupos-familiares` muestra un +aviso con el conteo de grupos sin titular y un filtro para encontrarlos. + +De los demás miembros se expone sólo `nro_socio`, `apellido`, `nombre`, +`categoria`, `cuotas_impagas` y `monto_adeudado`. **No** se expone DNI, fecha de +nacimiento ni localidad: es PII de otra persona y no hace falta para el caso de uso. + +--- + +## Códigos de invitación + +Los emite el club desde **`/socios/app-movil`** (módulo SOCIOS del ERP). No hay +auto-registro: el padrón no tiene emails, así que no hay a qué mandarle nada. + +- **Formato:** Crockford base32 (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`, sin I/L/O/U), + 10 caracteres, presentados como `XXXXX-XXXXX`. Espacio: 2^50 ≈ 1,1×10^15. +- **Al canjear** se aplica el mapeo Crockford (`O→0`, `I→1`, `L→1`), así que un + socio que tipea `l` en vez de `1` entra igual. +- **En la base sólo vive `sha256(codigo_normalizado || INVITACIONES_PEPPER)`**, + calculado en Node (`src/lib/invitaciones.ts`). El pepper nunca toca la base: + un dump de Postgres no alcanza para fuerza bruta offline. +- **Un solo uso**, garantizado por `UPDATE ... WHERE usado_at IS NULL ... RETURNING` + en una sola sentencia (no SELECT-después-UPDATE). Bajo concurrencia, el segundo + canje espera el lock, reevalúa la condición y no matchea. +- **Un solo código vivo por socio**, garantizado por un índice único parcial. + Reemitir revoca el anterior. +- **Vigencia** por defecto 14 días (1–90). +- **Emisión masiva:** tope de 1.000 por tanda (`listar_socios_para_emision`), con + el total real de candidatos visible en pantalla para saber cuántos quedan para + la tanda siguiente. Entran los socios sin cuenta activa y sin código vigente + —los de código **vencido** también, porque reemitir revoca el anterior— y se + excluyen los que tienen `fecha_baja`. El filtro por categoría y el tope se + resuelven en SQL: aplicarlos en JS sobre una consulta ya paginada devolvía + "los de esa categoría que además caen en la primera página". + +### Cuentas de socios y el módulo Seguridad + +Cada activación crea un usuario en `auth.users` (hasta ~8.400). La pantalla +**Seguridad → Usuarios** las excluye explícitamente y pagina hasta agotar: sin +eso, el staff —que son un puñado y se crearon primero— se caía del listado. +Las cuentas de socios se administran desde `/socios/app-movil`. + +### Rate limit + +10 intentos por IP cada 15 minutos; al pasarse, 1 hora de bloqueo. El contador +vive en la tabla `canje_rate_limit` y no en memoria porque Vercel corre lambdas +sin estado compartido. + +La IP se toma de **`x-vercel-forwarded-for`** (o `x-real-ip` detrás de un proxy +propio), nunca de `x-forwarded-for` a secas: el cliente le puede anteponer +entradas y mandar una IP distinta por request, con lo que el limiter se vuelve +decorativo. + +**Cuando no hay ninguna cabecera confiable** —básicamente desarrollo local— la +clave del bucket pasa a derivarse del código intentado, y se emite un `warn` en +los logs. La tentación es usar un bucket fijo tipo `"desconocida"`, pero eso +significa que 11 requests de cualquiera bloquean **todas** las activaciones +durante una hora: un DoS trivial contra la funcionalidad entera. Es una +degradación consciente — sin un identificador de cliente confiable no se puede +limitar por cliente — y en Vercel, que es el despliegue real, la cabecera +siempre está. + +`registrar_intento_canje`, `limpiar_intento_canje`, `mobile_validar_invitacion` y +`mobile_canjear_invitacion` están revocadas de `anon` **y** de `authenticated`: +sólo se llegan con service_role, o sea sólo desde los route handlers. Por eso el +limiter no se puede saltear yendo directo a PostgREST. + +--- + +## Configuración + +| Variable | Dónde | Notas | +|---|---|---| +| `INVITACIONES_PEPPER` | Vercel (Production + Preview) y `.env.local` | ≥32 caracteres. `openssl rand -base64 48`. **Nunca** con prefijo `NEXT_PUBLIC_` | +| `SUPABASE_SERVICE_ROLE_KEY` | ya existente | Usado sólo por los endpoints de `/auth/*` | + +**Rotar `INVITACIONES_PEPPER` invalida todos los códigos pendientes de golpe.** +No rotarlo con una emisión masiva en la calle. + +### Supabase Auth + +- `enable_signup = false` — la anon key es pública; con signup abierto cualquiera + crea cuentas. Ya está en `supabase/config.toml`, pero **hay que aplicarlo también + en el Dashboard del proyecto cloud**: `config.toml` sólo gobierna el stack local. +- **SMTP es prerequisito de lanzamiento.** Con signup cerrado, el único camino de + recuperación de contraseña es `resetPasswordForEmail`, que necesita SMTP. Sin + eso, un socio que olvida la clave se convierte en trabajo manual del club. +- `enable_confirmations` está en `false` y las cuentas se crean con + `email_confirm: true`. Conviene activarlo una vez que haya SMTP: hoy, si el + socio se equivoca al tipear el mail, queda sin forma de recuperar la cuenta. + +--- + +## CORS + +Si la app es nativa (React Native / Expo) no hace falta nada. Si es una PWA en +otro origen, agregar `OPTIONS` y una allowlist de orígenes. + +La auth va por header `Authorization` y **no por cookies**, así que **no hay +superficie CSRF**. Queda escrito para que a nadie se le ocurra agregar auth por +cookie más adelante sin darse cuenta de lo que cambia. diff --git a/package-lock.json b/package-lock.json index 170e483..7aa5884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "react-dom": "^18", "react-hook-form": "^7.71.2", "recharts": "^3.8.0", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", @@ -7604,6 +7605,12 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index d0e58db..8c0a37d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react-dom": "^18", "react-hook-form": "^7.71.2", "recharts": "^3.8.0", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/app/(dashboard)/security/usuarios/actions.ts b/src/app/(dashboard)/security/usuarios/actions.ts index 1edcf6e..4beda5d 100644 --- a/src/app/(dashboard)/security/usuarios/actions.ts +++ b/src/app/(dashboard)/security/usuarios/actions.ts @@ -5,6 +5,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { isAdmin } from "@/lib/permissions"; import { revalidatePath } from "next/cache"; import type { UsuarioSistema } from "@/types/security"; +import type { User } from "@supabase/supabase-js"; async function requireAdmin() { const supabase = await createClient(); @@ -30,11 +31,31 @@ export async function getUsuarios(): Promise { await requireAdmin(); const admin = createAdminClient(); - const { - data: { users }, - error: usersError, - } = await admin.auth.admin.listUsers({ perPage: 1000 }); - if (usersError) throw new Error(usersError.message); + // Se pagina hasta agotar en vez de pedir una sola página de 1000: desde la + // app móvil, cada socio que activa su cuenta crea un usuario en Auth (hasta + // ~8.400). Con una sola página, el staff —que son un puñado y se crearon + // primero— se caía del listado por completo. + const PER_PAGE = 1000; + const users: User[] = []; + for (let pagina = 1; ; pagina++) { + const { data, error: usersError } = await admin.auth.admin.listUsers({ + page: pagina, + perPage: PER_PAGE, + }); + if (usersError) throw new Error(usersError.message); + users.push(...data.users); + if (data.users.length < PER_PAGE) break; + } + + // Las cuentas de la app móvil no son usuarios del ERP: no tienen ni pueden + // tener un rol (lo impide el trigger trg_usuarios_roles_excluye_socios), así + // que en esta pantalla sólo serían ruido. Se administran desde + // /socios/app-movil. Se excluyen también las revocadas: siguen siendo cuentas + // de socios, no de staff. + const { data: cuentasSocios } = await admin + .from("socios_usuarios") + .select("user_id"); + const esDeSocio = new Set((cuentasSocios ?? []).map((c) => c.user_id)); // Get all user-role assignments const { data: userRoles } = await admin @@ -54,7 +75,9 @@ export async function getUsuarios(): Promise { }); } - return users.map((u) => { + return users + .filter((u) => !esDeSocio.has(u.id)) + .map((u) => { const roleInfo = rolesMap.get(u.id); return { id: u.id, diff --git a/src/app/(dashboard)/socios/app-movil/actions.ts b/src/app/(dashboard)/socios/app-movil/actions.ts new file mode 100644 index 0000000..b6bda6e --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/actions.ts @@ -0,0 +1,287 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { + formatearCodigo, + generarCodigo, + hashCodigo, + prefijoCodigo, +} from "@/lib/invitaciones"; +import type { + CandidatoEmision, + CodigoEmitido, + FilaAppMovil, +} from "@/types/app-movil"; + +/** + * Emisión y administración de los códigos de la app móvil. + * + * Todas las operaciones pasan por RPCs que hacen su propio chequeo de RBAC + * con `permiso_modulo_todos_los_roles`, así que el gate no depende de que + * estas funciones se acuerden de validar. Se usa el cliente de cookies + * (`createClient`) y no el admin: la RPC necesita el `auth.uid()` del operador + * para el gate y para registrar quién emitió cada código. + */ + +const MAX_LOTE = 1000; + +export async function getEstadoAppMovil(params: { + search?: string; + estado?: string; + page?: number; + pageSize?: number; +}): Promise<{ data: FilaAppMovil[]; total: number }> { + const supabase = await createClient(); + const page = params.page ?? 1; + const pageSize = params.pageSize ?? 50; + + const { data, error } = await supabase.rpc("listar_estado_app_movil", { + p_search: params.search?.trim() || null, + p_estado: params.estado || "todos", + p_limit: pageSize, + p_offset: (page - 1) * pageSize, + }); + + if (error) throw new Error(traducirError(error.message)); + + const filas = (data ?? []) as (FilaAppMovil & { total_filas: number })[]; + // `total_filas` viene repetido en cada fila (count(*) OVER () de la RPC): + // se lo saca del payload y se lo devuelve una sola vez. + return { + data: filas.map((fila) => { + const copia = { ...fila } as Partial; + delete copia.total_filas; + return copia as FilaAppMovil; + }), + total: filas.length > 0 ? Number(filas[0].total_filas) : 0, + }; +} + +/** + * Emite un código para un socio y lo devuelve EN CLARO, una sola vez. + * + * El código se genera acá (Node) y a la base sólo viaja su hash: ver + * `hashCodigo` en src/lib/invitaciones.ts. + */ +export async function emitirCodigo( + socioId: string, + dias = 14, +): Promise { + const supabase = await createClient(); + + const codigo = generarCodigo(); + + const { data, error } = await supabase.rpc("emitir_invitacion_socio", { + p_socio_id: socioId, + p_codigo_hash: hashCodigo(codigo), + p_prefijo: prefijoCodigo(codigo), + p_dias: dias, + }); + + if (error) throw new Error(traducirError(error.message)); + + const fila = Array.isArray(data) ? data[0] : data; + + const { data: socio } = await supabase + .from("socios") + .select("nro_socio, apellido, nombre") + .eq("id", socioId) + .single(); + + revalidatePath("/socios/app-movil"); + + return { + socio_id: socioId, + nro_socio: socio?.nro_socio ?? 0, + apellido: socio?.apellido ?? "", + nombre: socio?.nombre ?? "", + codigo: formatearCodigo(codigo), + expira_at: fila?.expira_at ?? "", + }; +} + +/** + * Emisión masiva. Un solo round-trip a la base: hacer N llamadas a + * `emitir_invitacion_socio` desde acá tardaría minutos con un lote grande y se + * comería el timeout de la lambda. + * + * Los socios que ya tienen cuenta activa se saltean en silencio del lado de la + * base; por eso el resultado puede tener menos filas que `socioIds`. + */ +export async function emitirCodigosMasivo( + socioIds: string[], + dias = 14, +): Promise { + if (socioIds.length === 0) return []; + if (socioIds.length > MAX_LOTE) { + throw new Error( + `El lote no puede superar los ${MAX_LOTE} socios. Filtre por categoría y emita por tandas.`, + ); + } + + const supabase = await createClient(); + + // El código en claro se conserva sólo en memoria de este request, indexado + // por socio, para poder devolverlo junto con el nombre. A la base va el hash. + const claros = new Map(); + const items = socioIds.map((socioId) => { + const codigo = generarCodigo(); + claros.set(socioId, codigo); + return { + socio_id: socioId, + codigo_hash: hashCodigo(codigo), + prefijo: prefijoCodigo(codigo), + }; + }); + + const { data, error } = await supabase.rpc("emitir_invitaciones_socios", { + p_items: items, + p_dias: dias, + }); + if (error) throw new Error(traducirError(error.message)); + + const emitidas = (data ?? []) as { socio_id: string; expira_at: string }[]; + if (emitidas.length === 0) return []; + + const { data: socios } = await supabase + .from("socios") + .select("id, nro_socio, apellido, nombre") + .in( + "id", + emitidas.map((e) => e.socio_id), + ); + + const porId = new Map((socios ?? []).map((s) => [s.id, s])); + + revalidatePath("/socios/app-movil"); + + return emitidas.map((e) => { + const s = porId.get(e.socio_id); + return { + socio_id: e.socio_id, + nro_socio: s?.nro_socio ?? 0, + apellido: s?.apellido ?? "", + nombre: s?.nombre ?? "", + codigo: formatearCodigo(claros.get(e.socio_id)!), + expira_at: e.expira_at, + }; + }); +} + +export async function revocarCodigo(socioId: string): Promise { + const supabase = await createClient(); + const { data, error } = await supabase.rpc("revocar_invitacion_socio", { + p_socio_id: socioId, + }); + if (error) throw new Error(traducirError(error.message)); + revalidatePath("/socios/app-movil"); + return Number(data ?? 0); +} + +/** + * Desvincula la cuenta de un socio y además la banea en Auth. + * + * Las dos cosas hacen falta: revocar la fila de `socios_usuarios` hace que las + * RPCs devuelvan vacío de inmediato, pero el access token que el teléfono ya + * tiene sigue siendo un token válido del proyecto hasta que expire (1 h). El + * ban lo corta ahí mismo y evita que pueda renovarlo. + */ +export async function desvincularCuenta(socioId: string): Promise { + const supabase = await createClient(); + + const { data: userId, error } = await supabase.rpc("desvincular_cuenta_socio", { + p_socio_id: socioId, + }); + if (error) throw new Error(traducirError(error.message)); + + if (userId) { + const admin = createAdminClient(); + const { error: banError } = await admin.auth.admin.updateUserById( + userId as string, + { ban_duration: "876000h" }, // ~100 años, igual que toggleUsuarioStatus + ); + // El vínculo ya se revocó y es lo que corta el acceso a los datos. Si el + // ban falla se avisa, pero no se revierte: dejar el vínculo vivo sería peor. + if (banError) { + throw new Error( + "La cuenta fue desvinculada, pero no se pudo bloquear el acceso en Auth. Revise el usuario en Seguridad.", + ); + } + } + + revalidatePath("/socios/app-movil"); +} + +/** + * Socios candidatos para la emisión masiva: sin cuenta activa, sin código + * vigente (los vencidos SÍ entran) y sin fecha de baja. + * + * El filtro por categoría y el tope del lote los resuelve la RPC en SQL. + * Filtrarlos acá sobre una consulta ya paginada devolvía "los de esta categoría + * que además caen en la primera página", que para una categoría grande son unos + * pocos o ninguno — y sin ninguna señal de que faltaba gente. + * + * `total` es el total real de candidatos, que puede ser mayor que `data.length` + * si supera el tope: así la pantalla puede avisar cuántos quedan afuera en vez + * de dar la tanda por completa. + */ +export async function getSociosParaEmision(categoriaId?: string): Promise<{ + data: CandidatoEmision[]; + total: number; +}> { + const supabase = await createClient(); + + const { data, error } = await supabase.rpc("listar_socios_para_emision", { + p_categoria_id: categoriaId ?? null, + p_limit: MAX_LOTE, + }); + if (error) throw new Error(traducirError(error.message)); + + const filas = (data ?? []) as (CandidatoEmision & { total_candidatos: number })[]; + + return { + data: filas.map((fila) => { + const copia = { ...fila } as Partial< + CandidatoEmision & { total_candidatos: number } + >; + delete copia.total_candidatos; + return copia as CandidatoEmision; + }), + total: filas.length > 0 ? Number(filas[0].total_candidatos) : 0, + }; +} + +/** + * Las RPCs levantan identificadores snake_case (contrato compartido con la API + * móvil, ver src/lib/api/rpc-errors.ts). Acá se traducen al español para el ERP. + * + * Lo NO mapeado se generaliza en vez de mostrarse tal cual. El caso concreto que + * lo motiva: dos operadores emitiendo lotes con socios en común hacen saltar el + * índice `ux_socios_invitaciones_socio_viva`, y sin esto el toast mostraba + * "duplicate key value violates unique constraint …" en la cara del usuario. + */ +function traducirError(mensaje: string): string { + const limpio = mensaje.trim(); + + const mapa: Record = { + sin_permiso: "No tiene permisos para realizar esta operación.", + no_autenticado: "Su sesión expiró. Vuelva a iniciar sesión.", + socio_ya_vinculado: "El socio ya tiene una cuenta activa en la app.", + socio_inexistente: "El socio no existe.", + sin_cuenta_vinculada: "El socio no tiene una cuenta vinculada.", + dias_fuera_de_rango: "La validez debe estar entre 1 y 90 días.", + lote_demasiado_grande: `El lote no puede superar los ${MAX_LOTE} socios.`, + cuenta_con_rol_erp: + "Esa cuenta tiene un rol del ERP asignado; no puede vincularse como socio.", + }; + if (limpio in mapa) return mapa[limpio]; + + if (limpio.includes("ux_socios_invitaciones_socio_viva")) { + return "Otro usuario emitió un código para alguno de estos socios al mismo tiempo. Recalcule el lote y reintente."; + } + + console.error("[app-movil] error no mapeado:", mensaje); + return "Ocurrió un error inesperado. Intente nuevamente."; +} diff --git a/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx new file mode 100644 index 0000000..881089b --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2, TriangleAlert } from "lucide-react"; +import { exportToExcel } from "@/lib/export"; +import { formatDate } from "@/lib/format"; +import { emitirCodigosMasivo, getSociosParaEmision } from "../actions"; +import { getCategoriasSociales } from "@/app/(dashboard)/socios/config/categorias/actions"; +import type { CandidatoEmision, CodigoEmitido } from "@/types/app-movil"; + +const MAX_LOTE = 1000; + +export default function EmisionMasivaPage() { + const [categorias, setCategorias] = useState<{ id: string; nombre: string }[]>([]); + const [categoriaId, setCategoriaId] = useState("todas"); + const [candidatos, setCandidatos] = useState([]); + // Total real de candidatos, que puede superar el tope del lote. Sin este dato + // la pantalla decía "se emitirán N" sin ninguna señal de cuántos quedaban afuera. + const [totalCandidatos, setTotalCandidatos] = useState(0); + const [emitidos, setEmitidos] = useState([]); + const [cargando, setCargando] = useState(false); + const [emitiendo, setEmitiendo] = useState(false); + + useEffect(() => { + getCategoriasSociales() + .then((cs) => setCategorias(cs.map((c) => ({ id: c.id, nombre: c.nombre })))) + .catch(() => toast.error("No se pudieron cargar las categorías")); + }, []); + + const previsualizar = useCallback(async () => { + setCargando(true); + setEmitidos([]); + try { + const res = await getSociosParaEmision( + categoriaId === "todas" ? undefined : categoriaId, + ); + setCandidatos(res.data); + setTotalCandidatos(res.total); + } catch (e) { + toast.error(e instanceof Error ? e.message : "No se pudo calcular el lote"); + } finally { + setCargando(false); + } + }, [categoriaId]); + + useEffect(() => { + previsualizar(); + }, [previsualizar]); + + async function emitir() { + setEmitiendo(true); + try { + const res = await emitirCodigosMasivo(candidatos.map((c) => c.socio_id)); + setEmitidos(res); + if (res.length === 0) { + toast.info("No se emitió ningún código: los socios del lote ya tienen cuenta."); + } else { + toast.success(`${res.length} código(s) emitido(s). Descargue el Excel.`); + } + await previsualizar(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "No se pudo emitir el lote"); + } finally { + setEmitiendo(false); + } + } + + async function descargar() { + await exportToExcel( + emitidos as unknown as Record[], + `codigos_app_movil_${new Date().toISOString().slice(0, 10)}`, + "Códigos", + [ + { key: "nro_socio", label: "Nro Socio" }, + { key: "apellido", label: "Apellido" }, + { key: "nombre", label: "Nombre" }, + { key: "codigo", label: "Código" }, + { key: "expira_at", label: "Vence" }, + ], + ); + } + + // La RPC ya limita el lote a MAX_LOTE, así que esto no es una condición de + // error sino un aviso de que quedan candidatos para una tanda siguiente. + const hayResto = totalCandidatos > candidatos.length; + const vencidos = candidatos.filter((c) => c.vencido).length; + + return ( +
+ + + + + 1. Elegir el lote + + +
+ + +
+ +

+ {cargando ? ( + "Calculando…" + ) : ( + <> + Se emitirán códigos para{" "} + {candidatos.length} socio(s) + {vencidos > 0 ? ( + <> + {" "} + ({vencidos} ya tenían un + código vencido, que se reemplaza) + + ) : null} + . + + )} +

+ + {hayResto ? ( +
+ +

+ Hay {totalCandidatos} socios + sin código, más de los {MAX_LOTE} que se pueden emitir de una + vez: un lote más grande se cortaría a la mitad y dejaría códigos + emitidos que nadie recibió. Se emitirán los primeros{" "} + {candidatos.length}; repita la operación (o filtre por categoría) + para cubrir los {totalCandidatos - candidatos.length} restantes. +

+
+ ) : null} +
+
+ + + + 2. Emitir y descargar + + +
+ + +
+ + {emitidos.length > 0 ? ( + <> +
+ +

+ Descargue el Excel ahora: los códigos no se guardan y no se + pueden volver a ver. Si sale de esta pantalla habrá que + reemitirlos. +

+
+
+ + + + + + + + + + + {emitidos.map((e) => ( + + + + + + + ))} + +
NroSocioCódigoVence
{e.nro_socio} + {e.apellido}, {e.nombre} + {e.codigo}{formatDate(e.expira_at)}
+
+ + ) : null} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/socios/app-movil/page.tsx b/src/app/(dashboard)/socios/app-movil/page.tsx new file mode 100644 index 0000000..d32adba --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ColumnDef } from "@tanstack/react-table"; +import { toast } from "sonner"; +import { DataTable } from "@/components/shared/DataTable"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { CodigoEmitidoDialog } from "@/components/socios/CodigoEmitidoDialog"; +import { useTabsStore } from "@/store/tabsStore"; +import { formatDate } from "@/lib/format"; +import { + desvincularCuenta, + emitirCodigo, + getEstadoAppMovil, + revocarCodigo, +} from "./actions"; +import { + ESTADO_APP_MOVIL_LABEL, + type CodigoEmitido, + type EstadoAppMovil, + type FilaAppMovil, +} from "@/types/app-movil"; + +const PAGE_SIZE = 50; + +const VARIANTE_BADGE: Record< + EstadoAppMovil, + "default" | "secondary" | "destructive" | "outline" +> = { + vinculado: "default", + codigo_vigente: "secondary", + codigo_vencido: "destructive", + sin_codigo: "outline", +}; + +type Confirmacion = { + titulo: string; + descripcion: string; + accion: () => Promise; +}; + +export default function AppMovilPage() { + const router = useRouter(); + const openTab = useTabsStore((s) => s.openTab); + + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [estado, setEstado] = useState("todos"); + const [isLoading, setIsLoading] = useState(true); + const [emitido, setEmitido] = useState(null); + const [confirmacion, setConfirmacion] = useState(null); + const [procesando, setProcesando] = useState(false); + + const fetchData = useCallback(async () => { + setIsLoading(true); + try { + const res = await getEstadoAppMovil({ + search, + estado, + page, + pageSize: PAGE_SIZE, + }); + setData(res.data); + setTotal(res.total); + } catch (e) { + toast.error(e instanceof Error ? e.message : "No se pudo cargar el listado"); + } finally { + setIsLoading(false); + } + }, [search, estado, page]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + async function handleEmitir(fila: FilaAppMovil) { + setProcesando(true); + try { + const codigo = await emitirCodigo(fila.socio_id); + setEmitido(codigo); + await fetchData(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "No se pudo emitir el código"); + } finally { + setProcesando(false); + } + } + + function pedirRevocar(fila: FilaAppMovil) { + setConfirmacion({ + titulo: "¿Revocar el código?", + descripcion: `El código pendiente de ${fila.apellido}, ${fila.nombre} dejará de servir. Si ya se lo entregó, deberá emitir uno nuevo.`, + accion: async () => { + await revocarCodigo(fila.socio_id); + toast.success("Código revocado"); + }, + }); + } + + function pedirDesvincular(fila: FilaAppMovil) { + setConfirmacion({ + titulo: "¿Desvincular la cuenta?", + descripcion: `${fila.apellido}, ${fila.nombre} perderá el acceso a la app y su cuenta quedará bloqueada. Para volver a darle acceso habrá que emitir un código nuevo.`, + accion: async () => { + await desvincularCuenta(fila.socio_id); + toast.success("Cuenta desvinculada"); + }, + }); + } + + async function confirmar() { + if (!confirmacion) return; + setProcesando(true); + try { + await confirmacion.accion(); + setConfirmacion(null); + await fetchData(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "No se pudo completar la operación"); + } finally { + setProcesando(false); + } + } + + const columns: ColumnDef[] = [ + { accessorKey: "nro_socio", header: "Nro" }, + { + id: "socio", + header: "Socio", + cell: ({ row }) => `${row.original.apellido}, ${row.original.nombre}`, + }, + { accessorKey: "dni", header: "DNI" }, + { + accessorKey: "estado", + header: "Estado app", + cell: ({ row }) => { + const e = row.original.estado; + return ( + {ESTADO_APP_MOVIL_LABEL[e]} + ); + }, + }, + { + id: "detalle", + header: "Detalle", + cell: ({ row }) => { + const f = row.original; + if (f.estado === "vinculado") { + return ( + + {f.email ?? "—"} + + ); + } + if (f.codigo_prefijo) { + return ( + + {f.codigo_prefijo}…{" "} + {f.expira_at ? `· vence ${formatDate(f.expira_at)}` : ""} + + ); + } + return ; + }, + }, + { + id: "ultimo_acceso", + header: "Último acceso", + cell: ({ row }) => + row.original.ultimo_acceso ? formatDate(row.original.ultimo_acceso) : "—", + }, + { + id: "acciones", + header: "", + cell: ({ row }) => { + const f = row.original; + return ( +
+ {f.estado === "vinculado" ? ( + + ) : ( + <> + + {f.estado !== "sin_codigo" ? ( + + ) : null} + + )} +
+ ); + }, + }, + ]; + + return ( +
+ { + openTab("/socios/app-movil/emision-masiva", "Emisión masiva"); + router.push("/socios/app-movil/emision-masiva"); + }} + > + Emisión masiva + + } + /> + +
+ +
+ + { + setSearch(q); + setPage(1); + }} + searchPlaceholder="Buscar por nro, apellido, nombre o DNI..." + isLoading={isLoading} + /> + + setEmitido(null)} /> + + { + if (!abierto) setConfirmacion(null); + }} + > + + + {confirmacion?.titulo} + + {confirmacion?.descripcion} + + + + Cancelar + { + e.preventDefault(); + confirmar(); + }} + > + Confirmar + + + + +
+ ); +} diff --git a/src/app/(dashboard)/socios/grupos-familiares/actions.ts b/src/app/(dashboard)/socios/grupos-familiares/actions.ts index fccb09d..5cc14f7 100644 --- a/src/app/(dashboard)/socios/grupos-familiares/actions.ts +++ b/src/app/(dashboard)/socios/grupos-familiares/actions.ts @@ -1,35 +1,67 @@ "use server"; import { createClient } from "@/lib/supabase/server"; +import { fetchAllRows } from "@/lib/supabase/fetch-all-rows"; import { revalidatePath } from "next/cache"; import type { GrupoFamiliar } from "@/types/socios"; export async function getGruposFamiliares(): Promise { const supabase = await createClient(); - const { data: grupos, error } = await supabase - .from("grupos_familiares") - .select("*, titular:socios!fk_grupos_familiares_titular(id,nro_socio,apellido,nombre)") - .order("created_at", { ascending: false }); - - if (error) throw new Error(error.message); - - // For each grupo, fetch members - const result: GrupoFamiliar[] = []; - for (const g of grupos ?? []) { - const { data: miembros } = await supabase + // fetchAllRows y no un .select() pelado: PostgREST corta en 1000 filas en + // silencio. Importa además del listado en sí porque esta pantalla es la vía + // para detectar los grupos sin titular, a los que la app móvil les niega las + // cuotas del grupo — un conteo truncado escondería justo los que hay que arreglar. + const grupos = await fetchAllRows((desde, hasta) => + supabase + .from("grupos_familiares") + .select( + "*, titular:socios!fk_grupos_familiares_titular(id,nro_socio,apellido,nombre)", + ) + // Desempate por id: `created_at` no es único y sin él la paginación de + // fetchAllRows puede repetir o saltear filas entre páginas. + .order("created_at", { ascending: false }) + .order("id") + .range(desde, hasta), + ); + + if (grupos.length === 0) return []; + + // Una sola consulta para todos los miembros en vez de una por grupo: el bucle + // anterior hacía N+1 round-trips y con unos cientos de grupos la pantalla + // tardaba o directamente se cortaba. + const miembros = await fetchAllRows<{ + id: string; + nro_socio: number; + apellido: string; + nombre: string; + grupo_familiar_id: string; + }>((desde, hasta) => + supabase .from("socios") - .select("id,nro_socio,apellido,nombre") - .eq("grupo_familiar_id", g.id) - .order("apellido"); - - result.push({ - ...g, - miembros: miembros ?? [], + .select("id,nro_socio,apellido,nombre,grupo_familiar_id") + .in( + "grupo_familiar_id", + grupos.map((g) => g.id), + ) + .order("apellido") + .order("id") + .range(desde, hasta), + ); + + const porGrupo = new Map(); + for (const m of miembros) { + const lista = porGrupo.get(m.grupo_familiar_id) ?? []; + lista.push({ + id: m.id, + nro_socio: m.nro_socio, + apellido: m.apellido, + nombre: m.nombre, }); + porGrupo.set(m.grupo_familiar_id, lista); } - return result; + return grupos.map((g) => ({ ...g, miembros: porGrupo.get(g.id) ?? [] })); } export async function createGrupoFamiliar(titularId: string, miembroIds: string[]) { diff --git a/src/app/(dashboard)/socios/grupos-familiares/page.tsx b/src/app/(dashboard)/socios/grupos-familiares/page.tsx index d485904..343f6e6 100644 --- a/src/app/(dashboard)/socios/grupos-familiares/page.tsx +++ b/src/app/(dashboard)/socios/grupos-familiares/page.tsx @@ -2,7 +2,14 @@ import { useEffect, useState, useCallback, useMemo, Fragment } from "react"; import { toast } from "sonner"; -import { ChevronDown, ChevronRight, Trash2, UserPlus, Search } from "lucide-react"; +import { + ChevronDown, + ChevronRight, + Trash2, + UserPlus, + Search, + TriangleAlert, +} from "lucide-react"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,11 +35,21 @@ export default function GruposFamiliaresPage() { const [expandedIds, setExpandedIds] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); const [search, setSearch] = useState(""); + const [soloSinTitular, setSoloSinTitular] = useState(false); + + // Los grupos sin titular no son una curiosidad: la app móvil le niega las + // cuotas del grupo a TODOS sus miembros (falla cerrada antes que inferir + // quién es el titular). Este filtro es la forma de encontrarlos y corregirlos. + const sinTitular = useMemo( + () => grupos.filter((g) => !g.titular).length, + [grupos], + ); const filtered = useMemo(() => { + const base = soloSinTitular ? grupos.filter((g) => !g.titular) : grupos; const q = search.trim().toLowerCase(); - if (!q) return grupos; - return grupos.filter((g) => + if (!q) return base; + return base.filter((g) => g.titular ? [ g.titular.apellido, @@ -41,7 +58,7 @@ export default function GruposFamiliaresPage() { ].some((v) => v?.toLowerCase().includes(q)) : false, ); - }, [grupos, search]); + }, [grupos, search, soloSinTitular]); const fetchData = useCallback(async () => { setIsLoading(true); @@ -98,6 +115,24 @@ export default function GruposFamiliaresPage() { /> + {sinTitular > 0 ? ( +
+ +

+ Hay {sinTitular} grupo(s) sin + titular designado. Sus miembros no pueden ver las cuotas del grupo + en la app móvil hasta que se les asigne uno. +

+ +
+ ) : null} +
diff --git a/src/app/api/mobile/v1/auth/canjear-invitacion/route.ts b/src/app/api/mobile/v1/auth/canjear-invitacion/route.ts new file mode 100644 index 0000000..d3a3855 --- /dev/null +++ b/src/app/api/mobile/v1/auth/canjear-invitacion/route.ts @@ -0,0 +1,217 @@ +import { createClient } from "@supabase/supabase-js"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { jsonOk, jsonError, newRequestId } from "@/lib/api/response"; +import { chequearLimite, limpiarLimite } from "@/lib/api/rate-limit"; +import { canjearInvitacionSchema } from "@/lib/schemas/mobile"; +import { hashCodigo, tieneFormaDeCodigo } from "@/lib/invitaciones"; +import { codigoDeError } from "@/lib/api/rpc-errors"; + +/** + * Paso 2 de la activación: canjea el código, CREA la cuenta y devuelve la sesión. + * + * La cuenta se crea acá y no antes por tres razones: + * * permite dejar `enable_signup = false` en Supabase Auth — con signup + * abierto, cualquiera con la anon key (que es pública) crea cuentas; + * * no existe el estado intermedio "tengo cuenta pero no estoy vinculado", + * que habría que diseñar, mostrar y soportar; + * * es un solo request desde la pantalla de activación. + * + * El ORDEN de los pasos es parte del diseño, no una casualidad: + * 1. rate limit → 429 + * 2. validar sin consumir → 400/409/410 + * 3. crear el usuario → 409 si el email ya existe + * 4. canjear + vincular → atómico en la base; si falla, se borra el usuario + * 5. iniciar sesión → 201 con la sesión + * + * Validar ANTES de crear evita dos cosas: consumir el código si la creación + * falla, y usar el endpoint como oráculo de "¿este email ya tiene cuenta?" sin + * haber demostrado antes tener un código válido. + */ +export async function POST(request: Request) { + const requestId = newRequestId(); + const admin = createAdminClient(); + + // El body se parsea antes del rate limit: el limiter usa el hash del código + // como clave alternativa cuando no hay IP confiable (ver chequearLimite). + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("body_invalido", 400, "El cuerpo no es JSON válido.", requestId); + } + + const parsed = canjearInvitacionSchema.safeParse(body); + if (!parsed.success) { + return jsonError( + "datos_invalidos", + 400, + parsed.error.issues[0].message, + requestId, + ); + } + const { codigo, email, password } = parsed.data; + + if (!tieneFormaDeCodigo(codigo)) { + return jsonError("codigo_invalido", 400, "El código no es válido.", requestId); + } + const codigoHash = hashCodigo(codigo); + + // ---- 1. rate limit ------------------------------------------------------ + const limite = await chequearLimite(admin, request, requestId, codigoHash); + if (!limite.permitido) return limite.response; + + // ---- 2. validar sin consumir ------------------------------------------- + const { data: val, error: errVal } = await admin.rpc("mobile_validar_invitacion", { + p_codigo_hash: codigoHash, + }); + if (errVal) { + console.error(`[${requestId}] mobile_validar_invitacion:`, errVal); + return jsonError( + "error_interno", + 500, + "Ocurrió un error inesperado. Intente nuevamente más tarde.", + requestId, + ); + } + const estado: string = (Array.isArray(val) ? val[0] : val)?.estado ?? "inexistente"; + if (estado === "expirada") { + return jsonError( + "codigo_expirado", + 410, + "El código venció. Solicite uno nuevo en el club.", + requestId, + ); + } + if (estado === "usada") { + return jsonError( + "codigo_ya_utilizado", + 409, + "El código ya fue utilizado.", + requestId, + ); + } + if (estado !== "valida") { + return jsonError("codigo_invalido", 400, "El código no es válido.", requestId); + } + + // ---- 3. crear el usuario Auth ------------------------------------------ + // email_confirm: true porque hoy `enable_confirmations = false` y no hay SMTP + // configurado. Quien llegó hasta acá demostró tener un código emitido por el + // club, así que el email no es la prueba de identidad. Cuando haya SMTP + // conviene activar la confirmación: si el socio se equivoca al tipear el + // mail, hoy se queda sin forma de recuperar la contraseña. + const { data: creado, error: errUser } = await admin.auth.admin.createUser({ + email, + password, + email_confirm: true, + }); + + if (errUser || !creado?.user) { + const msg = errUser?.message?.toLowerCase() ?? ""; + if (msg.includes("already") || msg.includes("registered") || msg.includes("exists")) { + return jsonError( + "email_en_uso", + 409, + "Ya existe una cuenta con ese email.", + requestId, + ); + } + console.error(`[${requestId}] createUser:`, errUser); + return jsonError( + "error_interno", + 500, + "No se pudo crear la cuenta. Intente nuevamente más tarde.", + requestId, + ); + } + const userId = creado.user.id; + + // ---- 4. canjear + vincular (atómico en la base) ------------------------- + const { data: canje, error: errCanje } = await admin.rpc( + "mobile_canjear_invitacion", + { p_codigo_hash: codigoHash, p_user_id: userId }, + ); + + if (errCanje) { + // Compensación: el usuario Auth se creó pero no quedó vinculado a ningún + // socio. Dejarlo sería dejar una cuenta huérfana que ocupa el email y no + // sirve para nada — y que impediría reintentar la activación, porque el + // reintento chocaría contra `email_en_uso` para siempre. + // + // supabase-js resuelve con { data, error } y NO rechaza ante un error de + // la API, así que hay que mirar `error`: un .catch() acá sería código + // muerto y una compensación fallida quedaría completamente muda. + const { error: errBorrado } = await admin.auth.admin.deleteUser(userId); + if (errBorrado) { + console.error( + `[${requestId}] COMPENSACIÓN FALLIDA: quedó el usuario Auth ${userId} (${email}) sin vínculo. Borrar a mano desde Seguridad.`, + errBorrado, + ); + } + + const cod = codigoDeError(errCanje.message); + if (cod === "codigo_invalido") { + // Perdió la carrera contra otro canje del mismo código entre el paso 2 y + // el 4. El UPDATE ... WHERE usado_at IS NULL es lo que garantiza que sólo + // uno de los dos gane. + return jsonError( + "codigo_ya_utilizado", + 409, + "El código ya fue utilizado.", + requestId, + ); + } + console.error(`[${requestId}] mobile_canjear_invitacion:`, errCanje); + return jsonError( + "error_interno", + 500, + "No se pudo activar la cuenta. Intente nuevamente más tarde.", + requestId, + ); + } + + const socio = Array.isArray(canje) ? canje[0] : canje; + + // ---- 5. iniciar sesión -------------------------------------------------- + // Con la anon key, no con el admin: se quiere exactamente la misma sesión que + // obtendría la app llamando a signInWithPassword por su cuenta. + const anon = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { auth: { persistSession: false, autoRefreshToken: false } }, + ); + const { data: sesion, error: errLogin } = await anon.auth.signInWithPassword({ + email, + password, + }); + + await limpiarLimite(admin, limite.claveHash); + + if (errLogin || !sesion?.session) { + // La cuenta quedó creada y vinculada: no se compensa nada. Sólo falló el + // login automático, y la app puede loguear con las credenciales que el + // socio acaba de elegir. + console.error(`[${requestId}] signInWithPassword tras canje:`, errLogin); + return jsonOk( + { socio, session: null, aviso: "Cuenta activada. Inicie sesión con su email y contraseña." }, + requestId, + undefined, + 201, + ); + } + + return jsonOk( + { + socio, + session: { + access_token: sesion.session.access_token, + refresh_token: sesion.session.refresh_token, + expires_at: sesion.session.expires_at, + token_type: sesion.session.token_type, + }, + }, + requestId, + undefined, + 201, + ); +} diff --git a/src/app/api/mobile/v1/auth/validar-invitacion/route.ts b/src/app/api/mobile/v1/auth/validar-invitacion/route.ts new file mode 100644 index 0000000..a98705d --- /dev/null +++ b/src/app/api/mobile/v1/auth/validar-invitacion/route.ts @@ -0,0 +1,89 @@ +import { createAdminClient } from "@/lib/supabase/admin"; +import { jsonOk, jsonError, newRequestId } from "@/lib/api/response"; +import { chequearLimite } from "@/lib/api/rate-limit"; +import { validarInvitacionSchema } from "@/lib/schemas/mobile"; +import { hashCodigo, tieneFormaDeCodigo } from "@/lib/invitaciones"; + +/** + * Paso 1 de la activación: "¿este código es válido, y de quién es?". + * + * No consume el código. Sirve para que la app pueda mostrar + * "¿Sos JUAN PÉREZ, socio 1234?" antes de pedirle email y contraseña, y para + * que un código vencido se detecte sin haber cargado nada. + */ +export async function POST(request: Request) { + const requestId = newRequestId(); + const admin = createAdminClient(); + + // El body se parsea antes del rate limit porque el limiter necesita el hash + // del código como clave alternativa cuando no hay una IP confiable (ver + // chequearLimite). Parsear JSON no toca la base y un body malformado no + // acerca a nadie a adivinar un código, así que no contarlo no debilita nada. + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("body_invalido", 400, "El cuerpo no es JSON válido.", requestId); + } + + const parsed = validarInvitacionSchema.safeParse(body); + if (!parsed.success || !tieneFormaDeCodigo(parsed.data.codigo)) { + return jsonError("codigo_invalido", 400, "El código no es válido.", requestId); + } + + const codigoHash = hashCodigo(parsed.data.codigo); + + const limite = await chequearLimite(admin, request, requestId, codigoHash); + if (!limite.permitido) return limite.response; + + const { data, error } = await admin.rpc("mobile_validar_invitacion", { + p_codigo_hash: codigoHash, + }); + if (error) { + console.error(`[${requestId}] mobile_validar_invitacion:`, error); + return jsonError( + "error_interno", + 500, + "Ocurrió un error inesperado. Intente nuevamente más tarde.", + requestId, + ); + } + + const fila = Array.isArray(data) ? data[0] : data; + const estado: string = fila?.estado ?? "inexistente"; + + // El detalle sólo se da cuando el hash matcheó una fila real, o sea cuando + // quien llama YA demostró conocer un código emitido. Para un hash que no + // existe la respuesta es genérica: así el endpoint no es un oráculo que + // permita distinguir "no existe" de "existe pero venció". + switch (estado) { + case "valida": + return jsonOk( + { + socio: { + nro_socio: fila.nro_socio, + apellido: fila.apellido, + nombre: fila.nombre, + }, + }, + requestId, + ); + case "expirada": + return jsonError( + "codigo_expirado", + 410, + "El código venció. Solicite uno nuevo en el club.", + requestId, + ); + case "usada": + return jsonError( + "codigo_ya_utilizado", + 409, + "El código ya fue utilizado.", + requestId, + ); + case "revocada": + default: + return jsonError("codigo_invalido", 400, "El código no es válido.", requestId); + } +} diff --git a/src/app/api/mobile/v1/mi/compras/[id]/route.ts b/src/app/api/mobile/v1/mi/compras/[id]/route.ts new file mode 100644 index 0000000..5842cf8 --- /dev/null +++ b/src/app/api/mobile/v1/mi/compras/[id]/route.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk, jsonError } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const { id } = await params; + if (!z.string().uuid().safeParse(id).success) { + // Un id con forma inválida se rechaza como "no encontrado" y no como + // "parámetro inválido": la respuesta es la misma que para un uuid ajeno, + // así que no hay forma de distinguir los dos casos desde afuera. + return jsonError("no_encontrado", 404, "La compra no existe.", requestId); + } + + const { data, error } = await supabase.rpc("mobile_mi_compra_detalle", { + p_venta_id: id, + }); + if (error) + return responseDeErrorRpc(error, requestId, "mobile_mi_compra_detalle"); + + // La RPC devuelve NULL tanto si la venta no existe como si es de otro socio. + // Se responde 404 en ambos casos, nunca 403: un 403 confirmaría que el uuid + // existe y pertenece a otra persona. + if (!data) { + return jsonError("no_encontrado", 404, "La compra no existe.", requestId); + } + + return jsonOk(data, requestId); +} diff --git a/src/app/api/mobile/v1/mi/compras/route.ts b/src/app/api/mobile/v1/mi/compras/route.ts new file mode 100644 index 0000000..ef4cff7 --- /dev/null +++ b/src/app/api/mobile/v1/mi/compras/route.ts @@ -0,0 +1,48 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk, jsonError } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; +import { listarPaginado } from "@/lib/api/paginacion"; +import { comprasQuerySchema, queryParams } from "@/lib/schemas/mobile"; + +/** + * Argentina no tiene horario de verano, así que el offset es fijo. Se escribe + * explícito porque `ventas.fecha` es timestamptz y la sesión de Postgres corre + * en UTC: sin offset, un "2026-08-13T23:59:59" se interpreta como UTC y una + * compra hecha a las 21:30 ART del último día del rango queda afuera del filtro + * del propio socio. + */ +const OFFSET_ART = "-03:00"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const parsed = comprasQuerySchema.safeParse(queryParams(request.url)); + if (!parsed.success) { + return jsonError( + "parametros_invalidos", + 400, + parsed.error.issues[0].message, + requestId, + ); + } + const { desde, hasta, page, per_page } = parsed.data; + + const res = await listarPaginado( + (limit, offset) => + supabase.rpc("mobile_mis_compras", { + // Los filtros llegan como fecha (YYYY-MM-DD) y la columna es + // timestamptz: hay que abarcar el día completo en hora argentina. + p_desde: desde ? `${desde}T00:00:00${OFFSET_ART}` : null, + p_hasta: hasta ? `${hasta}T23:59:59.999${OFFSET_ART}` : null, + p_limit: limit, + p_offset: offset, + }), + page, + per_page, + ); + if (!res.ok) return responseDeErrorRpc(res.error, requestId, "mobile_mis_compras"); + + return jsonOk(res.data, requestId, res.meta); +} diff --git a/src/app/api/mobile/v1/mi/cuotas/resumen/route.ts b/src/app/api/mobile/v1/mi/cuotas/resumen/route.ts new file mode 100644 index 0000000..7d6252f --- /dev/null +++ b/src/app/api/mobile/v1/mi/cuotas/resumen/route.ts @@ -0,0 +1,26 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const { data, error } = await supabase.rpc("mobile_mi_resumen_cuotas"); + if (error) + return responseDeErrorRpc(error, requestId, "mobile_mi_resumen_cuotas"); + + const fila = Array.isArray(data) ? data[0] : data; + // Un socio sin ninguna cuota cargada no es un error: el resumen es cero. + return jsonOk( + fila ?? { + cuotas_impagas: 0, + monto_adeudado: 0, + cuotas_pagadas: 0, + ultimo_periodo_pagado: null, + primer_periodo_impago: null, + }, + requestId, + ); +} diff --git a/src/app/api/mobile/v1/mi/cuotas/route.ts b/src/app/api/mobile/v1/mi/cuotas/route.ts new file mode 100644 index 0000000..1d2e670 --- /dev/null +++ b/src/app/api/mobile/v1/mi/cuotas/route.ts @@ -0,0 +1,38 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk, jsonError } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; +import { listarPaginado } from "@/lib/api/paginacion"; +import { cuotasQuerySchema, queryParams } from "@/lib/schemas/mobile"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const parsed = cuotasQuerySchema.safeParse(queryParams(request.url)); + if (!parsed.success) { + return jsonError( + "parametros_invalidos", + 400, + parsed.error.issues[0].message, + requestId, + ); + } + const { estado, desde, hasta, page, per_page } = parsed.data; + + const res = await listarPaginado( + (limit, offset) => + supabase.rpc("mobile_mis_cuotas", { + p_estado: estado, + p_desde: desde ?? null, + p_hasta: hasta ?? null, + p_limit: limit, + p_offset: offset, + }), + page, + per_page, + ); + if (!res.ok) return responseDeErrorRpc(res.error, requestId, "mobile_mis_cuotas"); + + return jsonOk(res.data, requestId, res.meta); +} diff --git a/src/app/api/mobile/v1/mi/grupo-familiar/cuotas/route.ts b/src/app/api/mobile/v1/mi/grupo-familiar/cuotas/route.ts new file mode 100644 index 0000000..2406734 --- /dev/null +++ b/src/app/api/mobile/v1/mi/grupo-familiar/cuotas/route.ts @@ -0,0 +1,45 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk, jsonError } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; +import { listarPaginado } from "@/lib/api/paginacion"; +import { cuotasQuerySchema, queryParams } from "@/lib/schemas/mobile"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const parsed = cuotasQuerySchema.safeParse(queryParams(request.url)); + if (!parsed.success) { + return jsonError( + "parametros_invalidos", + 400, + parsed.error.issues[0].message, + requestId, + ); + } + const { estado, desde, hasta, page, per_page } = parsed.data; + + // La RPC vuelve a chequear que el socio sea el titular del grupo; no confía + // en que el handler ya lo haya validado al pedir el listado de miembros. + const res = await listarPaginado( + (limit, offset) => + supabase.rpc("mobile_mi_grupo_familiar_cuotas", { + p_estado: estado, + p_desde: desde ?? null, + p_hasta: hasta ?? null, + p_limit: limit, + p_offset: offset, + }), + page, + per_page, + ); + if (!res.ok) + return responseDeErrorRpc( + res.error, + requestId, + "mobile_mi_grupo_familiar_cuotas", + ); + + return jsonOk(res.data, requestId, res.meta); +} diff --git a/src/app/api/mobile/v1/mi/grupo-familiar/route.ts b/src/app/api/mobile/v1/mi/grupo-familiar/route.ts new file mode 100644 index 0000000..a8a9be4 --- /dev/null +++ b/src/app/api/mobile/v1/mi/grupo-familiar/route.ts @@ -0,0 +1,25 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const { data, error } = await supabase.rpc("mobile_mi_grupo_familiar"); + // Los tres caminos de denegación (sin_grupo_familiar, grupo_sin_titular, + // no_es_titular) llegan como excepciones de la RPC y los mapea + // responseDeErrorRpc a 403 con su mensaje propio. + if (error) + return responseDeErrorRpc(error, requestId, "mobile_mi_grupo_familiar"); + + const miembros = data ?? []; + return jsonOk( + { + grupo_id: miembros[0]?.grupo_id ?? null, + miembros, + }, + requestId, + ); +} diff --git a/src/app/api/mobile/v1/mi/perfil/route.ts b/src/app/api/mobile/v1/mi/perfil/route.ts new file mode 100644 index 0000000..3eba0b8 --- /dev/null +++ b/src/app/api/mobile/v1/mi/perfil/route.ts @@ -0,0 +1,26 @@ +import { requireSocio } from "@/lib/api/mobile-auth"; +import { jsonOk, jsonError } from "@/lib/api/response"; +import { responseDeErrorRpc } from "@/lib/api/rpc-errors"; + +export async function GET(request: Request) { + const auth = await requireSocio(request); + if (!auth.ok) return auth.response; + const { supabase, requestId } = auth.ctx; + + const { data, error } = await supabase.rpc("mobile_mi_perfil"); + if (error) return responseDeErrorRpc(error, requestId, "mobile_mi_perfil"); + + const perfil = Array.isArray(data) ? data[0] : data; + if (!perfil) { + // requireSocio ya garantizó el vínculo, así que llegar acá significa que el + // socio fue borrado entre una consulta y la otra. Es un 404 honesto. + return jsonError( + "socio_inexistente", + 404, + "No se encontraron los datos del socio.", + requestId, + ); + } + + return jsonOk(perfil, requestId); +} diff --git a/src/components/socios/CodigoEmitidoDialog.tsx b/src/components/socios/CodigoEmitidoDialog.tsx new file mode 100644 index 0000000..700960b --- /dev/null +++ b/src/components/socios/CodigoEmitidoDialog.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Check, Copy, TriangleAlert } from "lucide-react"; +import { formatDate } from "@/lib/format"; +import type { CodigoEmitido } from "@/types/app-movil"; + +/** + * Muestra el código recién emitido. + * + * Es la única pantalla del sistema donde el código aparece en claro: la base + * guarda sólo su hash. De ahí la advertencia — si el operador cierra sin + * copiarlo, la única salida es reemitir (lo que invalida el anterior). + */ +export function CodigoEmitidoDialog({ + codigo, + onClose, +}: { + codigo: CodigoEmitido | null; + onClose: () => void; +}) { + const [copiado, setCopiado] = useState(false); + + async function copiar() { + if (!codigo) return; + await navigator.clipboard.writeText(codigo.codigo); + setCopiado(true); + setTimeout(() => setCopiado(false), 2000); + } + + return ( + { + if (!abierto) { + setCopiado(false); + onClose(); + } + }} + > + + + Código de activación + + {codigo + ? `${codigo.apellido}, ${codigo.nombre} — socio N.º ${codigo.nro_socio}` + : ""} + + + +
+
+

+ {codigo?.codigo} +

+ {codigo?.expira_at ? ( +

+ Vence el {formatDate(codigo.expira_at)} +

+ ) : null} +
+ +
+ +

+ Anote o copie el código antes de cerrar: por seguridad no se guarda + y no se puede volver a ver. Si se pierde, hay que emitir uno nuevo. +

+
+
+ + + + + +
+
+ ); +} diff --git a/src/lib/api/mobile-auth.ts b/src/lib/api/mobile-auth.ts new file mode 100644 index 0000000..83793ff --- /dev/null +++ b/src/lib/api/mobile-auth.ts @@ -0,0 +1,132 @@ +import "server-only"; +import type { NextResponse } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { createBearerClient } from "@/lib/supabase/bearer"; +import { jsonError, newRequestId } from "./response"; + +/** + * Autenticación de los route handlers de la app móvil. + * + * Es el ÚNICO lugar donde se resuelve auth.uid() → socio_id. Ningún handler + * construye su propio cliente Supabase ni acepta un socio_id por parámetro: + * reciben el contexto ya resuelto y el cliente ya atado al token del socio. + * Esa es la razón de que devuelva `supabase` adentro del contexto — hace + * estructuralmente incómodo terminar usando el admin client por accidente. + */ + +export type SocioContext = { + userId: string; + socioId: string; + nroSocio: number; + supabase: SupabaseClient; + requestId: string; +}; + +export type AuthResult = + | { ok: true; ctx: SocioContext } + | { ok: false; response: NextResponse }; + +function tokenDelHeader(request: Request): string | null { + const header = request.headers.get("authorization"); + if (!header) return null; + const [esquema, token] = header.split(" "); + if (!token || esquema.toLowerCase() !== "bearer") return null; + const limpio = token.trim(); + return limpio.length > 0 ? limpio : null; +} + +function noAutenticado(requestId: string, mensaje: string): NextResponse { + const res = jsonError("no_autenticado", 401, mensaje, requestId); + res.headers.set("WWW-Authenticate", "Bearer"); + return res; +} + +/** + * Resuelve el socio del request, o devuelve la respuesta de error ya armada. + * + * Uso en cada handler: + * const auth = await requireSocio(request); + * if (!auth.ok) return auth.response; + * const { supabase, socioId, requestId } = auth.ctx; + * + * Falla cerrado en todos los caminos: sin header → 401; token inválido o + * vencido → 401; token válido sin vínculo → 403; vínculo revocado → 403. + */ +export async function requireSocio(request: Request): Promise { + const requestId = newRequestId(); + + const token = tokenDelHeader(request); + if (!token) { + return { + ok: false, + response: noAutenticado(requestId, "Falta el token de autenticación."), + }; + } + + const supabase = createBearerClient(token); + + // Una sola ida y vuelta que resuelve la identidad Y valida el token. + // No se llama a auth.getUser() a propósito: PostgREST verifica la firma del + // JWT con el secreto del proyecto ANTES de poblar auth.uid(), así que un + // token manipulado o vencido falla acá mismo. Llamar además a GoTrue sería + // un round-trip extra por request que no agrega ninguna garantía. + const { data, error } = await supabase.rpc("mobile_contexto_socio"); + + if (error) { + // Un token vencido o manipulado tiene que dar 401, no 500. PostgREST lo + // reporta como PGRST301 ("JWT expired") y familia, pero un token que ni + // siquiera parsea puede ser rechazado antes, con otra forma de error; por + // eso se mira también el mensaje y el status. Sin este segundo camino, un + // token basura terminaba en "error interno" y la app no sabía que le + // bastaba con renovar la sesión. + const code = (error as { code?: string }).code ?? ""; + const status = (error as { status?: number }).status; + const msg = (error.message ?? "").toLowerCase(); + const esProblemaDeToken = + code.startsWith("PGRST3") || + code === "42501" || + status === 401 || + msg.includes("jwt") || + msg.includes("token"); + if (esProblemaDeToken) { + return { + ok: false, + response: noAutenticado(requestId, "El token no es válido o expiró."), + }; + } + console.error(`[${requestId}] mobile_contexto_socio:`, error); + return { + ok: false, + response: jsonError( + "error_interno", + 500, + "Ocurrió un error inesperado. Intente nuevamente más tarde.", + requestId, + ), + }; + } + + const fila = Array.isArray(data) ? data[0] : data; + if (!fila?.socio_id) { + return { + ok: false, + response: jsonError( + "cuenta_no_vinculada", + 403, + "Su cuenta no está vinculada a ningún socio. Contacte al club.", + requestId, + ), + }; + } + + return { + ok: true, + ctx: { + userId: fila.user_id as string, + socioId: fila.socio_id as string, + nroSocio: fila.nro_socio as number, + supabase, + requestId, + }, + }; +} diff --git a/src/lib/api/paginacion.ts b/src/lib/api/paginacion.ts new file mode 100644 index 0000000..7a26751 --- /dev/null +++ b/src/lib/api/paginacion.ts @@ -0,0 +1,71 @@ +import "server-only"; +import type { ApiMeta } from "./response"; + +type ConTotal = { total_filas?: number | string | null }; +type RespuestaRpc = { data: T[] | null; error: { message?: string } | null }; + +/** + * Las RPCs de listado devuelven `total_filas` repetido en cada fila (viene de + * un `count(*) OVER ()`, que resuelve la paginación en una sola query en vez + * de dos). Acá se lo saca del payload y se lo mueve al `meta`, que es donde la + * app lo espera: repetirlo en cada elemento sería ruido en la respuesta. + */ +function separarTotal(filas: T[]): { + data: Omit[]; + total: number | null; +} { + // Sin filas no hay `total_filas` que leer, y eso NO significa total 0: puede + // ser una página más allá del final. Se devuelve null para que el llamador + // decida si hace falta recontar. + const total = filas.length > 0 ? Number(filas[0].total_filas ?? 0) : null; + const data = filas.map((fila) => { + const copia = { ...fila }; + delete copia.total_filas; + return copia; + }); + return { data, total }; +} + +/** Convierte page/per_page (1-based, como los ve la app) al offset que espera la RPC. */ +export function offsetDe(page: number, perPage: number): number { + return (page - 1) * perPage; +} + +/** + * Ejecuta una RPC paginada y arma `{ data, meta }`. + * + * El detalle que justifica el helper: si la página pedida cae más allá del + * final, la RPC devuelve cero filas y con ellas se pierde el `count(*) OVER ()`. + * Reportar `total: 0` ahí sería mentir — le diría a la app "no tenés ninguna + * cuota" cuando en realidad se pasó de página, y cualquier paginador construido + * sobre `meta.total` colapsaría. En ese caso —y sólo en ése— se hace una + * segunda llamada mínima (`limit 1, offset 0`) para recuperar el total real. + */ +export async function listarPaginado( + llamar: (limit: number, offset: number) => PromiseLike>, + page: number, + perPage: number, +): Promise< + | { ok: true; data: Omit[]; meta: ApiMeta } + | { ok: false; error: { message?: string } } +> { + const res = await llamar(perPage, offsetDe(page, perPage)); + if (res.error) return { ok: false, error: res.error }; + + const { data, total } = separarTotal(res.data ?? []); + + let totalFinal = total; + if (totalFinal === null) { + if (page === 1) { + // Primera página vacía: no hay nada, y el total es genuinamente 0. + totalFinal = 0; + } else { + const recuento = await llamar(1, 0); + if (recuento.error) return { ok: false, error: recuento.error }; + const primera = (recuento.data ?? [])[0]; + totalFinal = primera ? Number(primera.total_filas ?? 0) : 0; + } + } + + return { ok: true, data, meta: { page, per_page: perPage, total: totalFinal } }; +} diff --git a/src/lib/api/rate-limit.ts b/src/lib/api/rate-limit.ts new file mode 100644 index 0000000..f0c6a43 --- /dev/null +++ b/src/lib/api/rate-limit.ts @@ -0,0 +1,94 @@ +import "server-only"; +import type { NextResponse } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { hashClaveLimite, ipConfiable } from "@/lib/invitaciones"; +import { jsonError } from "./response"; + +/** + * Freno de fuerza bruta sobre los códigos de invitación. + * + * El contador vive en la tabla `canje_rate_limit` y no en memoria porque + * Vercel corre lambdas sin estado compartido: un Map de módulo arrancaría + * vacío en cada invocación y el límite sería decorativo. + * + * `registrar_intento_canje` está revocada de `anon` y de `authenticated`, así + * que sólo se puede llegar a ella con service_role — o sea, sólo desde acá. + * Eso es lo que impide saltear el limiter llamando la RPC de canje directo. + */ +export type ResultadoLimite = + | { permitido: true; claveHash: string } + | { permitido: false; response: NextResponse }; + +/** + * @param discriminante clave alternativa cuando no hay una IP confiable + * (se usa el hash del código intentado). + * + * Sobre el caso sin IP confiable: la tentación es meter todos esos requests en + * un bucket fijo ("desconocida"), pero eso hace que 11 intentos de cualquiera + * bloqueen las activaciones de TODOS los socios durante una hora — un DoS + * trivial contra la funcionalidad entera. Se usa entonces una clave derivada + * del código intentado: frena a alguien machacando un mismo código y, sobre + * todo, no deja que un request afecte a los demás. + * + * Es una degradación consciente: sin un identificador de cliente confiable no + * se puede limitar por cliente, y punto. En Vercel —que es el despliegue real— + * la cabecera siempre está, así que este camino es el de desarrollo local. + */ +export async function chequearLimite( + admin: SupabaseClient, + request: Request, + requestId: string, + discriminante: string, +): Promise { + const ip = ipConfiable(request); + if (!ip) { + console.warn( + `[${requestId}] sin cabecera de IP confiable: el rate limit de invitaciones degrada a por-código`, + ); + } + const claveHash = hashClaveLimite(ip ? `ip:${ip}` : `cod:${discriminante}`); + + const { data, error } = await admin.rpc("registrar_intento_canje", { + p_ip_hash: claveHash, + }); + + if (error) { + // Falla cerrado: si no se puede contar el intento, no se procesa el + // intento. Lo contrario convertiría una caída de la base en una ventana + // sin límite justo cuando menos se puede verificar qué está pasando. + console.error(`[${requestId}] registrar_intento_canje:`, error); + return { + permitido: false, + response: jsonError( + "error_interno", + 500, + "Ocurrió un error inesperado. Intente nuevamente más tarde.", + requestId, + ), + }; + } + + const fila = Array.isArray(data) ? data[0] : data; + if (fila?.bloqueado) { + const retryAfter = Number(fila.retry_after ?? 3600); + const res = jsonError( + "demasiados_intentos", + 429, + "Demasiados intentos. Espere unos minutos e intente nuevamente.", + requestId, + { retry_after: retryAfter }, + ); + res.headers.set("Retry-After", String(retryAfter)); + return { permitido: false, response: res }; + } + + return { permitido: true, claveHash }; +} + +/** Se llama tras un canje exitoso: quien tenía un código válido no es un atacante. */ +export async function limpiarLimite( + admin: SupabaseClient, + claveHash: string, +): Promise { + await admin.rpc("limpiar_intento_canje", { p_ip_hash: claveHash }); +} diff --git a/src/lib/api/response.ts b/src/lib/api/response.ts new file mode 100644 index 0000000..c5742ba --- /dev/null +++ b/src/lib/api/response.ts @@ -0,0 +1,65 @@ +import "server-only"; +import { NextResponse } from "next/server"; + +/** + * Envelope de respuesta de la API móvil. + * + * Los ~48 `actions.ts` del ERP hacen `throw new Error(error.message)`, que + * propaga el mensaje crudo de Postgres/PostgREST al cliente. Acá no: la API es + * pública en internet y esos mensajes filtran nombres de tablas, columnas y + * constraints. Todo sale por `jsonError`, con un código estable para la app y + * un mensaje en español para el usuario. + */ + +export type ApiMeta = { + page: number; + per_page: number; + total: number; +}; + +/** Cabeceras comunes a toda respuesta de la API. */ +export const API_HEADERS: Record = { + // Ni las respuestas ni el token deben quedar en la CDN de Vercel ni en + // ningún proxy intermedio: todo lo que devuelve esta API es privado del socio. + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", +}; + +function withHeaders(res: NextResponse, requestId: string): NextResponse { + for (const [k, v] of Object.entries(API_HEADERS)) res.headers.set(k, v); + res.headers.set("X-Request-Id", requestId); + return res; +} + +/** Identificador de correlación: el socio lo ve, y aparece en los logs de Vercel. */ +export function newRequestId(): string { + return crypto.randomUUID(); +} + +export function jsonOk( + data: T, + requestId: string, + meta?: ApiMeta, + status = 200, +): NextResponse { + return withHeaders( + NextResponse.json(meta ? { data, meta } : { data }, { status }), + requestId, + ); +} + +export function jsonError( + code: string, + status: number, + message: string, + requestId: string, + extra?: Record, +): NextResponse { + return withHeaders( + NextResponse.json( + { error: { code, message, ...extra }, request_id: requestId }, + { status }, + ), + requestId, + ); +} diff --git a/src/lib/api/rpc-errors.ts b/src/lib/api/rpc-errors.ts new file mode 100644 index 0000000..aed9388 --- /dev/null +++ b/src/lib/api/rpc-errors.ts @@ -0,0 +1,85 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { jsonError } from "./response"; + +/** + * Traducción de los errores que levantan las RPCs de la app móvil a + * (status HTTP, mensaje en español). + * + * IMPORTANTE — contrato: las funciones de la migración + * `20260813000001_app_movil_socios.sql` hacen `RAISE EXCEPTION 'no_es_titular'` + * y no `RAISE EXCEPTION 'Sólo el titular puede...'`. Esos identificadores + * snake_case son **contrato de API, no mensajes para humanos**. El instinto de + * quien lea la migración va a ser "mejorarlos" a una frase en español; hacerlo + * rompe este mapeo en silencio y todos los errores pasan a ser 500 genéricos. + * Si hay que cambiar un identificador, hay que cambiarlo en los dos lados. + */ +const MAPA: Record = { + // Identidad / autorización + cuenta_no_vinculada: [ + 403, + "Su cuenta no está vinculada a ningún socio. Contacte al club.", + ], + sin_grupo_familiar: [403, "No pertenece a un grupo familiar."], + grupo_sin_titular: [ + 403, + "El grupo familiar no tiene titular designado. Contacte al club.", + ], + no_es_titular: [ + 403, + "Sólo el titular del grupo familiar puede ver esta información.", + ], + sin_permiso: [403, "No tiene permisos para realizar esta operación."], + no_autenticado: [401, "No autenticado."], + + // Invitaciones + codigo_invalido: [400, "El código no es válido."], + socio_ya_vinculado: [409, "El socio ya tiene una cuenta activa."], + socio_inexistente: [404, "El socio no existe."], + sin_cuenta_vinculada: [404, "El socio no tiene una cuenta vinculada."], + dias_fuera_de_rango: [400, "La validez debe estar entre 1 y 90 días."], + lote_demasiado_grande: [400, "El lote no puede superar los 1.000 socios."], + + // Invariante socio ≠ staff (triggers de la migración) + cuenta_con_rol_erp: [ + 409, + "La cuenta tiene un rol del ERP asignado; no puede vincularse como socio.", + ], + cuenta_vinculada_a_socio: [ + 409, + "La cuenta está vinculada a un socio de la app; no puede recibir un rol del ERP.", + ], +}; + +/** ¿Este error de Postgres corresponde a un código conocido del contrato? */ +export function codigoDeError(message: string | undefined): string | null { + if (!message) return null; + const limpio = message.trim(); + return limpio in MAPA ? limpio : null; +} + +/** + * Convierte un error de una RPC en una NextResponse. + * + * Lo no mapeado es un 500 genérico: el detalle real va a `console.error` junto + * al requestId, para poder correlacionarlo en los logs de Vercel sin + * mostrárselo a nadie. + */ +export function responseDeErrorRpc( + error: { message?: string } | null, + requestId: string, + contexto: string, +): NextResponse { + const codigo = codigoDeError(error?.message); + if (codigo) { + const [status, mensaje] = MAPA[codigo]; + return jsonError(codigo, status, mensaje, requestId); + } + console.error(`[${requestId}] ${contexto}:`, error); + return jsonError( + "error_interno", + 500, + "Ocurrió un error inesperado. Intente nuevamente más tarde.", + requestId, + ); +} diff --git a/src/lib/invitaciones.ts b/src/lib/invitaciones.ts new file mode 100644 index 0000000..eafee16 --- /dev/null +++ b/src/lib/invitaciones.ts @@ -0,0 +1,143 @@ +import "server-only"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Códigos de invitación de la app móvil: generación, normalización y hash. + * + * Es el único lugar del sistema donde un código existe en claro. La base + * guarda sólo sha256(codigo_normalizado || PEPPER); ver el comentario de + * `socios_invitaciones` en la migración 20260813000001. + */ + +/** + * Alfabeto Crockford base32: sin I, L, O ni U. + * + * Sin I/L/O porque se confunden con 1 y 0 cuando alguien tipea un código + * leído de un papel. Sin U porque su ausencia evita que el generador produzca + * palabras ofensivas por casualidad — con ~8.400 códigos emitidos, eso pasa. + */ +const ALFABETO = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const LONGITUD = 10; + +/** + * Genera un código nuevo. + * + * `byte & 31` no introduce sesgo de módulo porque 32 divide exacto a 256: + * cada carácter del alfabeto tiene exactamente 8 de los 256 valores posibles + * de un byte. Por eso no hace falta rejection sampling. + * + * Espacio: 32^10 = 2^50 ≈ 1,1×10^15. + */ +export function generarCodigo(): string { + const bytes = randomBytes(LONGITUD); + let out = ""; + for (let i = 0; i < LONGITUD; i++) out += ALFABETO[bytes[i] & 31]; + return out; +} + +/** Presentación para el mostrador y el Excel: `XXXXX-XXXXX`. El guión es cosmético. */ +export function formatearCodigo(codigo: string): string { + const c = normalizarCodigo(codigo); + return `${c.slice(0, 5)}-${c.slice(5)}`; +} + +/** + * Normaliza lo que tipeó el socio antes de hashear. + * + * Aplica el mapeo estándar de Crockford (O→0, I→1, L→1) además de mayúsculas y + * limpieza de separadores: alguien que lee "K7M2P-QX9RT" de un papel y escribe + * una `l` minúscula en vez de un `1` entra igual. Sin esto, el error de + * tipeo más común del alfabeto se convierte en "código inválido". + */ +export function normalizarCodigo(entrada: string): string { + return entrada + .toUpperCase() + .replace(/[^0-9A-Z]/g, "") + .replace(/O/g, "0") + .replace(/[IL]/g, "1"); +} + +/** ¿Tiene la forma de un código? Se chequea antes de gastar un intento del rate limit. */ +export function tieneFormaDeCodigo(entrada: string): boolean { + const c = normalizarCodigo(entrada); + if (c.length !== LONGITUD) return false; + for (const ch of c) if (!ALFABETO.includes(ch)) return false; + return true; +} + +function pepper(): string { + const p = process.env.INVITACIONES_PEPPER; + if (!p || p.length < 32) { + // Falla ruidosamente: sin pepper, los hashes de la base quedarían + // vulnerables a fuerza bruta offline, y peor, serían incompatibles con los + // emitidos antes. Es preferible un 500 que una emisión silenciosamente insegura. + throw new Error( + "INVITACIONES_PEPPER no está configurada (se requieren al menos 32 caracteres)", + ); + } + return p; +} + +/** + * Hash de un código, en el formato hex que espera un parámetro `bytea` de una + * RPC vía PostgREST (`\x...`). + * + * No es bcrypt/argon2 a propósito: el código no es una clave elegida por una + * persona sino un token de un CSPRNG con 2^50 de espacio, así que un KDF lento + * sólo agregaría latencia al canje legítimo sin cambiar la economía del ataque. + * El pepper —que vive en el entorno, nunca en la base— es lo que hace inútil + * un dump de Postgres. + */ +export function hashCodigo(codigo: string): string { + const normalizado = normalizarCodigo(codigo); + const h = createHash("sha256") + .update(normalizado + pepper(), "utf8") + .digest("hex"); + return `\\x${h}`; +} + +/** Los primeros 4 caracteres, que se guardan en claro para identificar el código emitido. */ +export function prefijoCodigo(codigo: string): string { + return normalizarCodigo(codigo).slice(0, 4); +} + +/** + * Hash de la clave del rate limiter. + * + * Se usa el mismo pepper: `canje_rate_limit` es un contador, no un registro de + * quién intentó qué, y no hay razón para guardar IPs de socios en claro. + */ +export function hashClaveLimite(clave: string): string { + const h = createHash("sha256") + .update(`rl:${clave}:${pepper()}`, "utf8") + .digest("hex"); + return `\\x${h}`; +} + +/** + * IP del cliente, sólo si viene de una cabecera que el cliente NO puede setear. + * + * `x-vercel-forwarded-for` lo escribe la edge de Vercel y no es falsificable + * desde afuera. NO se usa `x-forwarded-for` a secas: el cliente le puede + * anteponer entradas y mandar una IP distinta en cada request, con lo cual el + * limiter se vuelve un no-op. `x-real-ip` se acepta como segunda opción para + * despliegues detrás de un reverse proxy propio que la sobrescriba. + * + * Devuelve null cuando no hay ninguna cabecera confiable — típicamente + * corriendo local. Ver `chequearLimite` para qué se hace en ese caso. + */ +export function ipConfiable(request: Request): string | null { + return ( + request.headers.get("x-vercel-forwarded-for") ?? + request.headers.get("x-real-ip") ?? + null + ); +} + +/** Comparación en tiempo constante, para chequeos de igualdad sobre secretos. */ +export function igualSeguro(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} diff --git a/src/lib/nav-config.ts b/src/lib/nav-config.ts index 4b0c03d..93c5e98 100644 --- a/src/lib/nav-config.ts +++ b/src/lib/nav-config.ts @@ -23,6 +23,7 @@ export const NAV_MODULES: NavModule[] = [ { label: "Socios Morosos", href: "/socios/morosos" }, { label: "Cuotas", href: "/socios/cuotas" }, { label: "Padrón", href: "/socios/padron" }, + { label: "App Móvil — Códigos", href: "/socios/app-movil" }, sep, { label: "Socios por Categorías", href: "/socios/reportes/categorias" }, { label: "Socios por Edades", href: "/socios/reportes/edades" }, diff --git a/src/lib/schemas/mobile.ts b/src/lib/schemas/mobile.ts new file mode 100644 index 0000000..a9d1eee --- /dev/null +++ b/src/lib/schemas/mobile.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +/** + * Validación de entrada de la API móvil. + * + * Es el primer schema de input de query/paginación del repo: el ERP parsea + * `searchParams` a mano en cada página. Acá no alcanza con eso, porque estos + * valores vienen de internet y no de un formulario propio. + * + * Nota: el clamp de `per_page` está TAMBIÉN en las funciones SQL + * (`least(greatest(...))`). No es redundancia por las dudas: las RPCs son + * alcanzables por PostgREST directo con el JWT del socio, sin pasar por estos + * handlers, así que el límite tiene que existir en los dos lados. + */ + +const pagina = z.coerce.number().int().min(1).default(1); +const porPagina = z.coerce.number().int().min(1).max(100).default(50); + +export const paginacionSchema = z.object({ + page: pagina, + per_page: porPagina, +}); + +export const cuotasQuerySchema = z.object({ + estado: z.enum(["todas", "impagas", "pagas"]).default("todas"), + desde: z.iso.date().optional(), + hasta: z.iso.date().optional(), + page: pagina, + per_page: porPagina, +}); + +export type CuotasQuery = z.infer; + +export const comprasQuerySchema = z.object({ + desde: z.iso.date().optional(), + hasta: z.iso.date().optional(), + page: pagina, + per_page: porPagina, +}); + +export type ComprasQuery = z.infer; + +/** El código se normaliza en `normalizarCodigo` antes de hashear; acá sólo se acota el largo. */ +export const validarInvitacionSchema = z.object({ + codigo: z.string().trim().min(1, "Ingrese el código").max(40, "Código inválido"), +}); + +export const canjearInvitacionSchema = z.object({ + codigo: z.string().trim().min(1, "Ingrese el código").max(40, "Código inválido"), + email: z.string().trim().toLowerCase().email("Email inválido"), + // 8 caracteres es el mínimo que ya usa el ABM de usuarios del ERP + // (usuarioCreateSchema en src/lib/schemas/security.ts); se mantiene igual + // para no tener dos políticas de contraseña distintas en el mismo proyecto. + password: z + .string() + .min(8, "La contraseña debe tener al menos 8 caracteres") + .max(72, "La contraseña es demasiado larga"), +}); + +export type CanjearInvitacion = z.infer; + +/** + * Convierte los searchParams de una URL en un objeto plano para el schema. + * Los valores ausentes se omiten para que los `.default()` de Zod apliquen. + */ +export function queryParams(url: string): Record { + const out: Record = {}; + const sp = new URL(url).searchParams; + for (const [k, v] of sp.entries()) if (v !== "") out[k] = v; + return out; +} diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index ecc6f06..0b2b8db 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -1,3 +1,7 @@ +// Guard de build: este módulo usa SUPABASE_SERVICE_ROLE_KEY y bypassea RLS. +// Importarlo desde un Client Component pasa a ser un error de compilación en +// vez de un `undefined` silencioso en runtime. +import "server-only"; import { createClient } from "@supabase/supabase-js"; export function createAdminClient() { diff --git a/src/lib/supabase/bearer.ts b/src/lib/supabase/bearer.ts new file mode 100644 index 0000000..7f22d03 --- /dev/null +++ b/src/lib/supabase/bearer.ts @@ -0,0 +1,27 @@ +import "server-only"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +/** + * Cliente Supabase atado al access token de un socio de la app móvil. + * + * Es el cuarto cliente del proyecto, junto a server.ts (cookies), client.ts + * (browser) y admin.ts (service-role). Existe porque la app mobile manda el + * token por header `Authorization: Bearer`, no por cookie: ni el cliente de + * cookies ni el de browser sirven. + * + * Usa la anon key, así que las peticiones corren con los permisos del socio — + * que son deliberadamente ninguno sobre las tablas. Lo único que puede hacer + * este cliente es llamar a las funciones `mobile_*` con GRANT a `authenticated`. + */ +export function createBearerClient(accessToken: string): SupabaseClient { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + global: { headers: { Authorization: `Bearer ${accessToken}` } }, + // No hay nada que persistir ni refrescar: el cliente vive lo que dura el + // request. El refresh del token lo maneja supabase-js en el teléfono. + auth: { persistSession: false, autoRefreshToken: false }, + }, + ); +} diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index b6e1464..17b38ec 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -2,6 +2,19 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; export async function updateSession(request: NextRequest) { + // Los route handlers de /api se autentican con `Authorization: Bearer`, no + // con cookies, y tienen que contestar 401 JSON. El redirect 307 a /login de + // más abajo le devolvería HTML de la pantalla de login a un cliente que + // espera JSON, que es indistinguible de un bug del endpoint. + // + // Esto está duplicado con la exclusión de `api/` en el matcher de + // src/middleware.ts a propósito: el matcher es la optimización (evita un + // round-trip a GoTrue por request), este guard es la corrección, y sobrevive + // a que alguien edite el matcher sin acordarse de por qué estaba así. + if (request.nextUrl.pathname.startsWith("/api/")) { + return NextResponse.next({ request }); + } + let supabaseResponse = NextResponse.next({ request, }); diff --git a/src/middleware.ts b/src/middleware.ts index 1496e3a..698f2de 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -18,8 +18,10 @@ export const config = { * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) + * - api/ (la API móvil se autentica con Bearer, no con cookies; ver el + * guard equivalente al inicio de updateSession) * Feel free to modify this pattern to include more paths. */ - "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + "/((?!_next/static|_next/image|favicon.ico|api/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; diff --git a/src/types/app-movil.ts b/src/types/app-movil.ts new file mode 100644 index 0000000..88dbcf4 --- /dev/null +++ b/src/types/app-movil.ts @@ -0,0 +1,52 @@ +/** Estado de la cuenta de app móvil de un socio, tal como lo devuelve `listar_estado_app_movil`. */ +export type EstadoAppMovil = + | "sin_codigo" + | "codigo_vigente" + | "codigo_vencido" + | "vinculado"; + +export const ESTADO_APP_MOVIL_LABEL: Record = { + sin_codigo: "Sin código", + codigo_vigente: "Código vigente", + codigo_vencido: "Código vencido", + vinculado: "Vinculado", +}; + +export type FilaAppMovil = { + socio_id: string; + nro_socio: number; + apellido: string; + nombre: string; + dni: string; + estado: EstadoAppMovil; + codigo_prefijo: string | null; + expira_at: string | null; + email: string | null; + vinculado_at: string | null; + ultimo_acceso: string | null; +}; + +/** + * Resultado de emitir un código. `codigo` es el ÚNICO momento en que el código + * existe en claro: la base sólo guarda su hash, así que si el operador cierra + * el diálogo sin copiarlo hay que reemitir. + */ +/** Candidato a recibir un código en la emisión masiva. */ +export type CandidatoEmision = { + socio_id: string; + nro_socio: number; + apellido: string; + nombre: string; + categoria: string; + /** true si ya tenía un código pero venció: reemitir lo reemplaza. */ + vencido: boolean; +}; + +export type CodigoEmitido = { + socio_id: string; + nro_socio: number; + apellido: string; + nombre: string; + codigo: string; + expira_at: string; +}; diff --git a/supabase/config.toml b/supabase/config.toml index ae01c51..3b43f1e 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -160,7 +160,12 @@ enable_refresh_token_rotation = true # Requires enable_refresh_token_rotation = true. refresh_token_reuse_interval = 10 # Allow/disallow new user signups to your project. -enable_signup = true +# APAGADO desde la app móvil (…20260813000001): la anon key es pública, así que +# con signup abierto cualquiera puede crear cuentas ilimitadas contra el +# proyecto. Las únicas dos vías de alta son el ABM de Seguridad (service_role) y +# el canje de un código de invitación (POST /api/mobile/v1/auth/canjear-invitacion), +# que también corre con service_role. Ninguna de las dos pasa por signup público. +enable_signup = false # Allow/disallow anonymous sign-ins to your project. enable_anonymous_sign_ins = false # Allow/disallow testing manual linking of accounts @@ -195,7 +200,8 @@ web3 = 30 [auth.email] # Allow/disallow new user signups via email to your project. -enable_signup = true +# Ver el comentario de enable_signup en [auth]. +enable_signup = false # If enabled, a user will be required to confirm any email change on both the old, and new email # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true diff --git a/supabase/migrations/20260813000001_app_movil_socios.sql b/supabase/migrations/20260813000001_app_movil_socios.sql new file mode 100644 index 0000000..071c289 --- /dev/null +++ b/supabase/migrations/20260813000001_app_movil_socios.sql @@ -0,0 +1,1143 @@ +-- ============================================================================ +-- App móvil para socios — vínculo auth.users ↔ socios, invitaciones y API +-- +-- Contexto +-- -------- +-- Los socios del club (~8.400) van a instalar una app en el teléfono para ver +-- SUS PROPIAS cuotas sociales, su perfil y sus compras. Hasta esta migración +-- eso era imposible por dos razones: +-- +-- 1. No existía ningún vínculo entre `auth.users` y `socios`. La tabla +-- `socios` no tiene email ni teléfono ni user_id; los únicos +-- identificadores son `nro_socio` y `dni` (y el `dni` no siempre es real: +-- la migración legacy sintetizó los faltantes como dni = nro_socio). +-- +-- 2. El RBAC es todo-o-nada por módulo. `select_cuotas` (…000009) está +-- gateada en el permiso `socios:leer`, que es el MISMO permiso que da +-- lectura del padrón entero. Darle un rol a un socio para que vea su +-- deuda le mostraría la de los otros 8.399. +-- +-- Decisión de arquitectura +-- ------------------------ +-- El socio autenticado tiene **cero permisos de tabla**. No se agrega ninguna +-- política RLS sobre socios/cuotas/ventas: las políticas se OR-ean entre sí y +-- cada una nueva obliga a re-razonar si amplía el acceso de otro. En cambio, +-- toda lectura pasa por una función SECURITY DEFINER que deriva el socio +-- internamente desde auth.uid() y **no acepta ningún identificador de socio +-- como parámetro**. Sin parámetro no hay IDOR: no hay nada que manipular. +-- +-- La propiedad de seguridad que sostiene el diseño, verificable en psql: un +-- usuario `authenticated` sin filas en `usuarios_roles` ve 0 filas en socios, +-- cuotas y ventas. Su JWT contra PostgREST directo devuelve []. Lo único que +-- puede hacer es llamar a las funciones de este archivo que tengan GRANT +-- EXECUTE. Auditar la seguridad de la API móvil = leer estas funciones. +-- +-- Nota sobre el helper de permisos: los objetos nuevos usan +-- `permiso_modulo_todos_los_roles` (…20260812000003) y no el viejo +-- `get_user_modulo_permission` (…000009), que resuelve con LIMIT 1 sin +-- ORDER BY y elige un rol arbitrario cuando el usuario tiene más de uno. +-- ============================================================================ + +-- ============================================================================ +-- TABLAS +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- socios_invitaciones — códigos de un solo uso emitidos por el club +-- +-- El socio no se auto-registra: alguien del club emite un código y se lo +-- entrega. El código NUNCA se guarda en claro. Se guarda +-- sha256(codigo_normalizado || INVITACIONES_PEPPER), calculado en Node +-- (src/lib/invitaciones.ts), por tres razones: +-- +-- * El pepper no toca la base. Un dump de Postgres no alcanza para fuerza +-- bruta offline: 50 bits son ~10^15 hashes, caro pero no imposible para +-- una GPU; sin el pepper, imposible. +-- * pgcrypto vive en el schema `extensions`, no en `public`. Una función +-- con `SET search_path = public` NO resuelve digest(). Hashear en SQL +-- obligaría a relajar el search_path de la función que consume el código. +-- * Emisión y validación comparten exactamente una función de hash, así que +-- no pueden divergir. +-- +-- No se usa bcrypt/argon2 a propósito: el código no es una clave elegida por +-- un humano, es un token de un CSPRNG con 2^50 de espacio. Un KDF lento sólo +-- agregaría latencia al canje legítimo. +-- ---------------------------------------------------------------------------- +CREATE TABLE socios_invitaciones ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + socio_id uuid NOT NULL REFERENCES socios(id) ON DELETE CASCADE, + codigo_hash bytea NOT NULL, + -- Primeros 4 caracteres del código, en claro. Sirve para que en el mostrador + -- se pueda identificar cuál de los códigos emitidos es el que tiene el socio + -- en la mano, sin poder reconstruirlo (quedan 6 chars = 2^30 de incógnita). + codigo_prefijo text NOT NULL, + expira_at timestamptz NOT NULL, + usado_at timestamptz, + usado_por_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + revocada_at timestamptz, + revocada_por uuid REFERENCES auth.users(id) ON DELETE SET NULL, + creada_por uuid NOT NULL REFERENCES auth.users(id), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT socios_invitaciones_prefijo_len CHECK (char_length(codigo_prefijo) = 4) +); + +CREATE UNIQUE INDEX ux_socios_invitaciones_hash ON socios_invitaciones (codigo_hash); +CREATE INDEX idx_socios_invitaciones_socio ON socios_invitaciones (socio_id); + +-- Red de seguridad: como máximo un código vivo por socio. `emitir_invitacion_socio` +-- ya revoca el anterior antes de insertar; este índice parcial garantiza que no +-- haya forma de saltearlo (por ejemplo desde un script con service_role). Sin +-- esto podrían coexistir dos códigos válidos y el socio no sabría cuál usar. +CREATE UNIQUE INDEX ux_socios_invitaciones_socio_viva ON socios_invitaciones (socio_id) + WHERE usado_at IS NULL AND revocada_at IS NULL; + +-- ---------------------------------------------------------------------------- +-- socios_usuarios — el vínculo auth.users ↔ socios +-- +-- Es 1:1 en ambas direcciones, pero con historial: los índices únicos son +-- PARCIALES sobre las filas vivas (revocado_at IS NULL). Así, desvincular una +-- cuenta deja rastro en vez de borrar la evidencia de quién tuvo acceso a los +-- datos de qué socio y durante cuánto tiempo. +-- +-- socio_id va con ON DELETE RESTRICT a propósito: borrar un socio que tiene +-- una cuenta móvil activa debe fallar ruidosamente, no dejar la cuenta +-- huérfana apuntando a la nada. +-- ---------------------------------------------------------------------------- +CREATE TABLE socios_usuarios ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + socio_id uuid NOT NULL REFERENCES socios(id) ON DELETE RESTRICT, + invitacion_id uuid REFERENCES socios_invitaciones(id) ON DELETE SET NULL, + vinculado_at timestamptz NOT NULL DEFAULT now(), + revocado_at timestamptz, + revocado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX ux_socios_usuarios_user_activo ON socios_usuarios (user_id) WHERE revocado_at IS NULL; +CREATE UNIQUE INDEX ux_socios_usuarios_socio_activo ON socios_usuarios (socio_id) WHERE revocado_at IS NULL; +CREATE INDEX idx_socios_usuarios_socio ON socios_usuarios (socio_id); + +-- ---------------------------------------------------------------------------- +-- canje_rate_limit — freno de fuerza bruta sobre los códigos, sin Redis +-- +-- El stack no tiene rate limiter ni KV, y Vercel corre lambdas sin estado +-- compartido: un contador en memoria sería un no-op (cada invocación arranca +-- con el contador en cero). La base ES el estado compartido, y un upsert por +-- intento es despreciable frente al costo del request. +-- +-- La IP se guarda hasheada con el mismo pepper: esta tabla no es un registro +-- de quién intentó qué, es sólo un contador. +-- +-- La cuenta que justifica los parámetros (10 intentos / 15 min, castigo 1 h): +-- el código tiene 10 caracteres de un alfabeto de 32 = 2^50 ≈ 1,1×10^15 +-- combinaciones. Con ~8.400 códigos vivos, un intento al azar acierta con +-- probabilidad 7,5×10^-12. A 10 intentos cada 15 minutos por IP hacen falta +-- del orden de 10^11 IP-años para un acierto esperado. El límite no está para +-- hacer la fuerza bruta difícil: está para que no valga la pena intentarla ni +-- con una botnet. +-- ---------------------------------------------------------------------------- +CREATE TABLE canje_rate_limit ( + ip_hash bytea PRIMARY KEY, + ventana_inicio timestamptz NOT NULL DEFAULT now(), + intentos integer NOT NULL DEFAULT 0, + bloqueado_hasta timestamptz +); + +-- ============================================================================ +-- RLS de las tablas nuevas +-- +-- socios_invitaciones y canje_rate_limit quedan SIN NINGUNA POLÍTICA a +-- propósito: con RLS activa, la ausencia de política deniega todo. La pantalla +-- del ERP las lee a través de `listar_estado_app_movil`, una función DEFINER +-- que devuelve columnas whitelisteadas y nunca el codigo_hash. Mismo criterio +-- que `configuracion` en …20260812000003. +-- ============================================================================ +ALTER TABLE socios_invitaciones ENABLE ROW LEVEL SECURITY; +ALTER TABLE socios_usuarios ENABLE ROW LEVEL SECURITY; +ALTER TABLE canje_rate_limit ENABLE ROW LEVEL SECURITY; + +-- socios_usuarios sí es legible (sólo lectura, sólo para quien ya puede leer +-- el padrón): la UI del ERP muestra qué socios tienen cuenta activa. +CREATE POLICY "select_socios_usuarios" ON socios_usuarios FOR SELECT TO authenticated + USING (permiso_modulo_todos_los_roles('socios', 'leer')); + +-- ============================================================================ +-- INVARIANTE: un socio de la app NUNCA es staff del ERP +-- +-- Es la mitigación estructural del riesgo más grave de esta feature. El +-- permiso `socios:leer` es todo-o-nada: si una cuenta móvil llegara a tener un +-- rol del ERP, su JWT dejaría de ver "sólo lo suyo" y pasaría a leer el padrón +-- completo vía PostgREST directo, sin pasar por ninguna de las funciones de +-- este archivo. +-- +-- Se hace con dos triggers y no con una convención escrita en el README porque +-- una convención se olvida y un EXCEPTION no. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION socios_usuarios_excluye_staff() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + -- Sólo aplica a vínculos vivos: revocar (poner revocado_at) siempre se permite. + IF NEW.revocado_at IS NULL + AND EXISTS (SELECT 1 FROM usuarios_roles ur WHERE ur.user_id = NEW.user_id) THEN + RAISE EXCEPTION 'cuenta_con_rol_erp'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_socios_usuarios_excluye_staff + BEFORE INSERT OR UPDATE ON socios_usuarios + FOR EACH ROW EXECUTE FUNCTION socios_usuarios_excluye_staff(); + +-- El espejo, que es el que más fácil se olvida. Sin este trigger, +-- `updateUsuarioRole()` en src/app/(dashboard)/security/usuarios/actions.ts le +-- asignaría alegremente el rol Administrador a la cuenta móvil de un socio +-- desde la pantalla de Seguridad, sin que nada lo impida. +CREATE OR REPLACE FUNCTION usuarios_roles_excluye_socios() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM socios_usuarios su + WHERE su.user_id = NEW.user_id AND su.revocado_at IS NULL + ) THEN + RAISE EXCEPTION 'cuenta_vinculada_a_socio'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_usuarios_roles_excluye_socios + BEFORE INSERT OR UPDATE ON usuarios_roles + FOR EACH ROW EXECUTE FUNCTION usuarios_roles_excluye_socios(); + +-- ============================================================================ +-- IDENTIDAD — el punto único de verdad del que cuelga todo lo demás +-- ============================================================================ + +-- Devuelve el socio del usuario autenticado, o NULL si no está vinculado. +-- Todas las funciones de lectura se cuelgan de ésta; ninguna acepta un +-- socio_id por parámetro. +CREATE OR REPLACE FUNCTION mobile_socio_actual() +RETURNS uuid +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT su.socio_id + FROM socios_usuarios su + WHERE su.user_id = auth.uid() + AND su.revocado_at IS NULL; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_socio_actual() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_socio_actual() TO authenticated; + +-- Lo que llama `requireSocio` en cada request. Una sola ida y vuelta que +-- resuelve la identidad Y valida el JWT: PostgREST verifica la firma del token +-- con el secreto del proyecto ANTES de poblar auth.uid(), así que si el token +-- es inválido o venció, la llamada falla con 401 sin llegar a ejecutarse. Eso +-- ahorra una llamada extra a GoTrue (auth.getUser()) por request. +-- +-- Devuelve 0 filas si el usuario no está vinculado → el helper responde 403. +CREATE OR REPLACE FUNCTION mobile_contexto_socio() +RETURNS TABLE (user_id uuid, socio_id uuid, nro_socio integer) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT auth.uid(), su.socio_id, s.nro_socio + FROM socios_usuarios su + JOIN socios s ON s.id = su.socio_id + WHERE su.user_id = auth.uid() + AND su.revocado_at IS NULL; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_contexto_socio() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_contexto_socio() TO authenticated; + +-- ============================================================================ +-- LECTURA — los datos del socio autenticado +-- +-- Patrón común a todas, con dos detalles que sostienen la seguridad: +-- +-- * `JOIN (SELECT mobile_socio_actual()) yo ON tabla.socio_id = yo.socio_id` +-- en vez de un WHERE con variable. Si el usuario no está vinculado, +-- mobile_socio_actual() es NULL y `socio_id = NULL` es NULL, no true: el +-- join no produce filas. Falla cerrado aunque el route handler tuviera un +-- bug y no devolviera el 403. +-- +-- * El LIMIT se clampea TAMBIÉN en SQL, no sólo en el schema Zod del +-- handler, porque estas funciones son alcanzables por PostgREST directo +-- con el JWT del socio. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION mobile_mi_perfil() +RETURNS TABLE ( + socio_id uuid, + nro_socio integer, + apellido text, + nombre text, + dni text, + categoria text, + metodo_cobranza text, + fecha_alta date, + fecha_baja date, + activo boolean, + antiguedad_meses integer, + localidad text, + email text +) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT + s.id, + s.nro_socio, + s.apellido, + s.nombre, + s.dni, + cs.nombre, + mc.nombre, + s.fecha_alta, + s.fecha_baja, + -- "Activo" en este sistema es fecha_baja NULL **y** una categoría que + -- cuenta como activa (cuenta_como_activo, …20260806000001). Ver el + -- comentario en src/types/socios.ts. + (s.fecha_baja IS NULL AND cs.cuenta_como_activo), + -- Antigüedad SIEMPRE calculada, nunca almacenada (convención del repo). + (EXTRACT(YEAR FROM age(COALESCE(s.fecha_baja, CURRENT_DATE), s.fecha_alta)) * 12 + + EXTRACT(MONTH FROM age(COALESCE(s.fecha_baja, CURRENT_DATE), s.fecha_alta)))::integer, + s.localidad, + u.email::text + FROM socios s + JOIN (SELECT mobile_socio_actual() AS socio_id) yo ON s.id = yo.socio_id + JOIN categorias_sociales cs ON cs.id = s.categoria_id + LEFT JOIN metodos_cobranza mc ON mc.id = s.metodo_cobranza_id + LEFT JOIN auth.users u ON u.id = auth.uid(); +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_perfil() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_perfil() TO authenticated; + +-- El caso de uso central de la app: qué cuotas debo y cuáles pagué. +-- `total_filas` viene de count(*) OVER (), que se evalúa antes del LIMIT, así +-- que la paginación se resuelve en una sola query en vez de dos. +CREATE OR REPLACE FUNCTION mobile_mis_cuotas( + p_estado text DEFAULT 'todas', -- 'todas' | 'impagas' | 'pagas' + p_desde date DEFAULT NULL, + p_hasta date DEFAULT NULL, + p_limit integer DEFAULT 50, + p_offset integer DEFAULT 0 +) +RETURNS TABLE ( + id uuid, + periodo date, + monto numeric, + pagada boolean, + fecha_pago timestamptz, + tipo_cuota text, + metodo_pago text, + total_filas bigint +) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT + c.id, c.periodo, c.monto, c.pagada, c.fecha_pago, + tc.nombre, mc.nombre, + count(*) OVER () + FROM cuotas c + JOIN (SELECT mobile_socio_actual() AS socio_id) yo ON c.socio_id = yo.socio_id + JOIN tipos_cuotas tc ON tc.id = c.tipo_cuota_id + LEFT JOIN metodos_cobranza mc ON mc.id = c.metodo_pago_id + WHERE ( + p_estado = 'todas' + OR (p_estado = 'impagas' AND NOT c.pagada) + OR (p_estado = 'pagas' AND c.pagada) + ) + AND (p_desde IS NULL OR c.periodo >= p_desde) + AND (p_hasta IS NULL OR c.periodo <= p_hasta) + -- Desempate por id: sin él, dos cuotas del mismo período pueden alternar de + -- orden entre páginas y una fila aparece dos veces o ninguna. + ORDER BY c.periodo DESC, c.id + LIMIT least(greatest(coalesce(p_limit, 50), 1), 100) + OFFSET greatest(coalesce(p_offset, 0), 0); +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mis_cuotas(text, date, date, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mis_cuotas(text, date, date, integer, integer) TO authenticated; + +-- La pantalla de inicio de la app. Se agrega en SQL y no en JS a propósito: +-- sumar NUMERIC(12,2) en JavaScript los convierte a float64 y el total puede +-- diferir en centavos del que muestra el ERP. +CREATE OR REPLACE FUNCTION mobile_mi_resumen_cuotas() +RETURNS TABLE ( + cuotas_impagas integer, + monto_adeudado numeric, + cuotas_pagadas integer, + ultimo_periodo_pagado date, + primer_periodo_impago date +) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT + count(*) FILTER (WHERE NOT c.pagada)::integer, + COALESCE(sum(c.monto) FILTER (WHERE NOT c.pagada), 0), + count(*) FILTER (WHERE c.pagada)::integer, + max(c.periodo) FILTER (WHERE c.pagada), + min(c.periodo) FILTER (WHERE NOT c.pagada) + FROM cuotas c + JOIN (SELECT mobile_socio_actual() AS socio_id) yo ON c.socio_id = yo.socio_id; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_resumen_cuotas() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_resumen_cuotas() TO authenticated; + +-- Compras del socio. `ventas.socio_id` ES el comprador; `ventas.usuario_id` es +-- el cajero que registró la venta — no confundirlos. +-- Las anuladas no se muestran: para el socio no existieron. +CREATE OR REPLACE FUNCTION mobile_mis_compras( + p_desde timestamptz DEFAULT NULL, + p_hasta timestamptz DEFAULT NULL, + p_limit integer DEFAULT 50, + p_offset integer DEFAULT 0 +) +RETURNS TABLE ( + id uuid, + fecha timestamptz, + total numeric, + metodo_pago text, + punto_venta text, + cantidad_items integer, + total_filas bigint +) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT + v.id, v.fecha, v.total, mc.nombre, d.nombre, + COALESCE((SELECT sum(vi.cantidad)::integer FROM ventas_items vi WHERE vi.venta_id = v.id), 0), + count(*) OVER () + FROM ventas v + JOIN (SELECT mobile_socio_actual() AS socio_id) yo ON v.socio_id = yo.socio_id + -- Desambiguación de FK obligatoria: `ventas` tiene más de una FK hacia estas + -- tablas, igual que en getVentas (src/app/(dashboard)/ventas/actions.ts). + LEFT JOIN metodos_cobranza mc ON mc.id = v.metodo_pago_id + LEFT JOIN depositos d ON d.id = v.punto_venta_id + WHERE NOT v.anulada + AND (p_desde IS NULL OR v.fecha >= p_desde) + AND (p_hasta IS NULL OR v.fecha <= p_hasta) + ORDER BY v.fecha DESC, v.id + LIMIT least(greatest(coalesce(p_limit, 50), 1), 100) + OFFSET greatest(coalesce(p_offset, 0), 0); +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mis_compras(timestamptz, timestamptz, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mis_compras(timestamptz, timestamptz, integer, integer) TO authenticated; + +-- Detalle de una compra. Devuelve jsonb (y no RETURNS TABLE) porque la forma +-- es anidada: cabecera + ítems. +-- +-- Devuelve NULL si la venta no existe O no es del socio autenticado: el +-- handler mapea NULL a 404, nunca a 403. Un 403 confirmaría que el uuid existe +-- y pertenece a otro, que es justo lo que no queremos decirle a nadie. +-- Los precios salen de ventas_items (congelados al momento de la venta), no de +-- items_ventas (que cambian con el tiempo). +CREATE OR REPLACE FUNCTION mobile_mi_compra_detalle(p_venta_id uuid) +RETURNS jsonb +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT jsonb_build_object( + 'id', v.id, + 'fecha', v.fecha, + 'total', v.total, + 'metodo_pago', mc.nombre, + 'punto_venta', d.nombre, + 'items', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'nombre', iv.nombre, + 'cantidad', vi.cantidad, + 'precio_unitario', vi.precio_unitario, + 'subtotal', vi.subtotal + ) ORDER BY iv.nombre) + FROM ventas_items vi + JOIN items_ventas iv ON iv.id = vi.item_id + WHERE vi.venta_id = v.id + ), '[]'::jsonb) + ) + FROM ventas v + JOIN (SELECT mobile_socio_actual() AS socio_id) yo ON v.socio_id = yo.socio_id + LEFT JOIN metodos_cobranza mc ON mc.id = v.metodo_pago_id + LEFT JOIN depositos d ON d.id = v.punto_venta_id + WHERE v.id = p_venta_id + AND NOT v.anulada; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_compra_detalle(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_compra_detalle(uuid) TO authenticated; + +-- ============================================================================ +-- GRUPO FAMILIAR — datos de terceros, la parte más delicada +-- +-- Regla: SÓLO el titular del grupo ve las cuotas del grupo. Si el grupo no +-- tiene titular designado, no lo ve nadie. +-- +-- El fail-closed con titular_id NULL es deliberado. En los datos migrados del +-- sistema legacy puede haber grupos sin titular, y es tentador inferirlo (el +-- socio más antiguo, el de menor nro_socio, cualquier miembro). Inferir sería +-- inventar una regla de autorización, y el costo de equivocarse es mostrarle a +-- alguien la deuda de un tercero. Para que esto no se vuelva un ticket de +-- soporte irresoluble, la pantalla /socios/grupos-familiares del ERP tiene un +-- filtro "Sin titular" que permite corregirlos. +-- +-- Minimización de datos: el RETURNS TABLE es una whitelist explícita. De los +-- otros miembros del grupo NO se expone dni, fecha_nacimiento, localidad ni +-- método de cobranza — no hacen falta para el caso de uso y son PII ajena. +-- Nunca SELECT *. +-- ============================================================================ + +-- Resuelve y valida el grupo del socio autenticado. Las dos funciones públicas +-- del grupo llaman a ésta, así que la regla de autorización está escrita una +-- sola vez. +CREATE OR REPLACE FUNCTION mobile_mi_grupo_titular() +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ +DECLARE + v_socio uuid := mobile_socio_actual(); + v_grupo uuid; + v_titular uuid; +BEGIN + IF v_socio IS NULL THEN + RAISE EXCEPTION 'cuenta_no_vinculada'; + END IF; + + SELECT s.grupo_familiar_id INTO v_grupo FROM socios s WHERE s.id = v_socio; + IF v_grupo IS NULL THEN + RAISE EXCEPTION 'sin_grupo_familiar'; + END IF; + + SELECT g.titular_id INTO v_titular FROM grupos_familiares g WHERE g.id = v_grupo; + IF v_titular IS NULL THEN + RAISE EXCEPTION 'grupo_sin_titular'; + END IF; + IF v_titular <> v_socio THEN + RAISE EXCEPTION 'no_es_titular'; + END IF; + + RETURN v_grupo; +END; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_grupo_titular() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_grupo_titular() TO authenticated; + +CREATE OR REPLACE FUNCTION mobile_mi_grupo_familiar() +RETURNS TABLE ( + grupo_id uuid, + socio_id uuid, + es_usuario_actual boolean, + nro_socio integer, + apellido text, + nombre text, + categoria text, + cuotas_impagas integer, + monto_adeudado numeric +) +LANGUAGE plpgsql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ +DECLARE + v_grupo uuid := mobile_mi_grupo_titular(); + v_socio uuid := mobile_socio_actual(); +BEGIN + RETURN QUERY + SELECT + v_grupo, s.id, (s.id = v_socio), + s.nro_socio, s.apellido, s.nombre, cs.nombre, + count(c.id) FILTER (WHERE NOT c.pagada)::integer, + COALESCE(sum(c.monto) FILTER (WHERE NOT c.pagada), 0) + FROM socios s + JOIN categorias_sociales cs ON cs.id = s.categoria_id + LEFT JOIN cuotas c ON c.socio_id = s.id + WHERE s.grupo_familiar_id = v_grupo + GROUP BY s.id, s.nro_socio, s.apellido, s.nombre, cs.nombre + ORDER BY (s.id = v_socio) DESC, s.apellido, s.nombre; +END; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_grupo_familiar() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_grupo_familiar() TO authenticated; + +-- Repite el chequeo de titular vía mobile_mi_grupo_titular(); no confía en que +-- el handler ya lo haya hecho al pedir el listado del grupo. +CREATE OR REPLACE FUNCTION mobile_mi_grupo_familiar_cuotas( + p_estado text DEFAULT 'todas', + p_desde date DEFAULT NULL, + p_hasta date DEFAULT NULL, + p_limit integer DEFAULT 50, + p_offset integer DEFAULT 0 +) +RETURNS TABLE ( + id uuid, + socio_id uuid, + nro_socio integer, + apellido text, + nombre text, + periodo date, + monto numeric, + pagada boolean, + fecha_pago timestamptz, + tipo_cuota text, + total_filas bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ +DECLARE + v_grupo uuid := mobile_mi_grupo_titular(); +BEGIN + RETURN QUERY + SELECT + c.id, s.id, s.nro_socio, s.apellido, s.nombre, + c.periodo, c.monto, c.pagada, c.fecha_pago, tc.nombre, + count(*) OVER () + FROM cuotas c + JOIN socios s ON s.id = c.socio_id + JOIN tipos_cuotas tc ON tc.id = c.tipo_cuota_id + WHERE s.grupo_familiar_id = v_grupo + AND ( + p_estado = 'todas' + OR (p_estado = 'impagas' AND NOT c.pagada) + OR (p_estado = 'pagas' AND c.pagada) + ) + AND (p_desde IS NULL OR c.periodo >= p_desde) + AND (p_hasta IS NULL OR c.periodo <= p_hasta) + ORDER BY c.periodo DESC, s.apellido, s.nombre, c.id + LIMIT least(greatest(coalesce(p_limit, 50), 1), 100) + OFFSET greatest(coalesce(p_offset, 0), 0); +END; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_mi_grupo_familiar_cuotas(text, date, date, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION mobile_mi_grupo_familiar_cuotas(text, date, date, integer, integer) TO authenticated; + +-- ============================================================================ +-- EMISIÓN DE INVITACIONES — lo llama el ERP, gateado por RBAC +-- +-- Los gates van DENTRO de las funciones y no sólo en el server action de +-- TypeScript, así valen también si alguien llama la RPC directo con su JWT. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION emitir_invitacion_socio( + p_socio_id uuid, + p_codigo_hash bytea, + p_prefijo text, + p_dias integer DEFAULT 14 +) +RETURNS TABLE (invitacion_id uuid, expira_at timestamptz) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user uuid := auth.uid(); +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'no_autenticado'; + END IF; + IF NOT permiso_modulo_todos_los_roles('socios', 'escribir') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + IF p_dias < 1 OR p_dias > 90 THEN + RAISE EXCEPTION 'dias_fuera_de_rango'; + END IF; + IF NOT EXISTS (SELECT 1 FROM socios s WHERE s.id = p_socio_id) THEN + RAISE EXCEPTION 'socio_inexistente'; + END IF; + IF EXISTS ( + SELECT 1 FROM socios_usuarios su + WHERE su.socio_id = p_socio_id AND su.revocado_at IS NULL + ) THEN + RAISE EXCEPTION 'socio_ya_vinculado'; + END IF; + + -- Reemitir invalida el código anterior. Sin esto quedarían dos códigos vivos + -- y el socio no sabría cuál usar (además el índice parcial único rebotaría + -- el insert de abajo). + UPDATE socios_invitaciones + SET revocada_at = now(), revocada_por = v_user + WHERE socio_id = p_socio_id + AND usado_at IS NULL + AND revocada_at IS NULL; + + RETURN QUERY + INSERT INTO socios_invitaciones (socio_id, codigo_hash, codigo_prefijo, expira_at, creada_por) + VALUES (p_socio_id, p_codigo_hash, p_prefijo, now() + make_interval(days => p_dias), v_user) + RETURNING socios_invitaciones.id, socios_invitaciones.expira_at; +END; +$$; + +REVOKE EXECUTE ON FUNCTION emitir_invitacion_socio(uuid, bytea, text, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION emitir_invitacion_socio(uuid, bytea, text, integer) TO authenticated; + +-- Emisión masiva. Un solo round-trip para hasta 1.000 socios: hacer 1.000 +-- llamadas a emitir_invitacion_socio desde el server action tardaría minutos y +-- se comería el timeout de la lambda. +-- p_items: [{"socio_id": "...", "codigo_hash": "\\x...", "prefijo": "ABCD"}, ...] +CREATE OR REPLACE FUNCTION emitir_invitaciones_socios( + p_items jsonb, + p_dias integer DEFAULT 14 +) +RETURNS TABLE (socio_id uuid, invitacion_id uuid, expira_at timestamptz) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_cant integer; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'no_autenticado'; + END IF; + IF NOT permiso_modulo_todos_los_roles('socios', 'escribir') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + IF p_dias < 1 OR p_dias > 90 THEN + RAISE EXCEPTION 'dias_fuera_de_rango'; + END IF; + + SELECT count(*) INTO v_cant FROM jsonb_array_elements(p_items); + IF v_cant > 1000 THEN + RAISE EXCEPTION 'lote_demasiado_grande'; + END IF; + + -- Dos sentencias secuenciales, no un CTE que modifique datos: dentro de un + -- único statement, el INSERT no vería el efecto del UPDATE que revoca, y el + -- índice parcial ux_socios_invitaciones_socio_viva rebotaría. Tampoco una + -- TEMP TABLE, que fallaría al llamar la función dos veces en la misma + -- transacción. + UPDATE socios_invitaciones i + SET revocada_at = now(), revocada_por = v_user + WHERE i.usado_at IS NULL + AND i.revocada_at IS NULL + AND i.socio_id IN ( + SELECT x.socio_id FROM jsonb_to_recordset(p_items) AS x(socio_id uuid) + ); + + -- Los socios ya vinculados se saltean en silencio: en un lote de 1.000 es + -- esperable que alguno ya tenga cuenta, y no es un error del operador. El + -- server action compara lo que pidió contra lo que devuelve esta función. + RETURN QUERY + INSERT INTO socios_invitaciones (socio_id, codigo_hash, codigo_prefijo, expira_at, creada_por) + SELECT x.socio_id, x.codigo_hash, x.prefijo, now() + make_interval(days => p_dias), v_user + FROM jsonb_to_recordset(p_items) AS x(socio_id uuid, codigo_hash bytea, prefijo text) + WHERE NOT EXISTS ( + SELECT 1 FROM socios_usuarios su + WHERE su.socio_id = x.socio_id AND su.revocado_at IS NULL + ) + RETURNING socios_invitaciones.socio_id, socios_invitaciones.id, socios_invitaciones.expira_at; +END; +$$; + +REVOKE EXECUTE ON FUNCTION emitir_invitaciones_socios(jsonb, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION emitir_invitaciones_socios(jsonb, integer) TO authenticated; + +CREATE OR REPLACE FUNCTION revocar_invitacion_socio(p_socio_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_n integer; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'no_autenticado'; + END IF; + IF NOT permiso_modulo_todos_los_roles('socios', 'escribir') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + + UPDATE socios_invitaciones + SET revocada_at = now(), revocada_por = v_user + WHERE socio_id = p_socio_id + AND usado_at IS NULL + AND revocada_at IS NULL; + + GET DIAGNOSTICS v_n = ROW_COUNT; + RETURN v_n; +END; +$$; + +REVOKE EXECUTE ON FUNCTION revocar_invitacion_socio(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION revocar_invitacion_socio(uuid) TO authenticated; + +-- Desvincular corta el acceso de una cuenta móvil. Requiere socios:eliminar +-- porque le saca el acceso a un socio, no es una operación de rutina. +-- +-- Devuelve el user_id para que el server action pueda además banear la cuenta +-- en Auth: revocar la fila sola deja el JWT vigente hasta que expire (1 h), y +-- durante esa hora las funciones ya devuelven vacío, pero el token sigue +-- siendo un token válido del proyecto. +CREATE OR REPLACE FUNCTION desvincular_cuenta_socio(p_socio_id uuid) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_target uuid; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'no_autenticado'; + END IF; + IF NOT permiso_modulo_todos_los_roles('socios', 'eliminar') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + + UPDATE socios_usuarios + SET revocado_at = now(), revocado_por = v_user + WHERE socio_id = p_socio_id + AND revocado_at IS NULL + RETURNING user_id INTO v_target; + + IF v_target IS NULL THEN + RAISE EXCEPTION 'sin_cuenta_vinculada'; + END IF; + + RETURN v_target; +END; +$$; + +REVOKE EXECUTE ON FUNCTION desvincular_cuenta_socio(uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION desvincular_cuenta_socio(uuid) TO authenticated; + +-- Alimenta la pantalla /socios/app-movil. Nunca devuelve codigo_hash. +CREATE OR REPLACE FUNCTION listar_estado_app_movil( + p_search text DEFAULT NULL, + p_estado text DEFAULT 'todos', -- 'todos'|'sin_codigo'|'codigo_vigente'|'codigo_vencido'|'vinculado' + p_limit integer DEFAULT 50, + p_offset integer DEFAULT 0 +) +RETURNS TABLE ( + socio_id uuid, + nro_socio integer, + apellido text, + nombre text, + dni text, + estado text, + codigo_prefijo text, + expira_at timestamptz, + email text, + vinculado_at timestamptz, + ultimo_acceso timestamptz, + total_filas bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ +BEGIN + IF NOT permiso_modulo_todos_los_roles('socios', 'leer') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + + RETURN QUERY + WITH base AS ( + SELECT + s.id, s.nro_socio, s.apellido, s.nombre, s.dni, + su.vinculado_at, u.email::text AS email, u.last_sign_in_at, + i.codigo_prefijo, i.expira_at, + CASE + WHEN su.id IS NOT NULL THEN 'vinculado' + WHEN i.id IS NULL THEN 'sin_codigo' + WHEN i.expira_at > now() THEN 'codigo_vigente' + ELSE 'codigo_vencido' + END AS estado + FROM socios s + LEFT JOIN socios_usuarios su + ON su.socio_id = s.id AND su.revocado_at IS NULL + LEFT JOIN auth.users u ON u.id = su.user_id + LEFT JOIN socios_invitaciones i + ON i.socio_id = s.id AND i.usado_at IS NULL AND i.revocada_at IS NULL + ) + SELECT + b.id, b.nro_socio, b.apellido, b.nombre, b.dni, + b.estado, b.codigo_prefijo, b.expira_at, + b.email, b.vinculado_at, b.last_sign_in_at, + count(*) OVER () + FROM base b + WHERE (p_estado = 'todos' OR b.estado = p_estado) + AND ( + p_search IS NULL OR p_search = '' OR + b.apellido ILIKE '%' || p_search || '%' OR + b.nombre ILIKE '%' || p_search || '%' OR + b.dni ILIKE '%' || p_search || '%' OR + b.nro_socio::text = p_search + ) + ORDER BY b.apellido, b.nombre, b.nro_socio + LIMIT least(greatest(coalesce(p_limit, 50), 1), 200) + OFFSET greatest(coalesce(p_offset, 0), 0); +END; +$$; + +REVOKE EXECUTE ON FUNCTION listar_estado_app_movil(text, text, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION listar_estado_app_movil(text, text, integer, integer) TO authenticated; + +-- Candidatos para la emisión masiva. +-- +-- Función aparte de `listar_estado_app_movil` y no un parámetro más de aquélla: +-- la de listado está paginada para una pantalla (clamp de 200) y ésta tiene que +-- devolver el lote entero. Mezclarlas hacía que la emisión masiva heredara en +-- silencio el tope de la pantalla y sólo emitiera para los primeros 200 socios. +-- +-- El filtro por categoría va ACÁ y no en el server action: filtrar en JS lo que +-- devuelve una consulta ya paginada da "los socios de esta categoría que además +-- caen en la primera página", que para una categoría de cientos de socios son +-- unos pocos o ninguno. +-- +-- Incluye tanto a los que nunca tuvieron código como a los que lo tienen +-- vencido: reemitir revoca el anterior, así que un código vencido no es motivo +-- para excluir a nadie del lote (si no, sólo se lo podría arreglar de a uno). +CREATE OR REPLACE FUNCTION listar_socios_para_emision( + p_categoria_id uuid DEFAULT NULL, + p_limit integer DEFAULT 1000 +) +RETURNS TABLE ( + socio_id uuid, + nro_socio integer, + apellido text, + nombre text, + categoria text, + vencido boolean, + total_candidatos bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ +BEGIN + IF NOT permiso_modulo_todos_los_roles('socios', 'escribir') THEN + RAISE EXCEPTION 'sin_permiso'; + END IF; + + RETURN QUERY + WITH candidatos AS ( + SELECT s.id, s.nro_socio, s.apellido, s.nombre, cs.nombre AS categoria, + (i.id IS NOT NULL) AS vencido + FROM socios s + JOIN categorias_sociales cs ON cs.id = s.categoria_id + LEFT JOIN socios_invitaciones i + ON i.socio_id = s.id + AND i.usado_at IS NULL + AND i.revocada_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM socios_usuarios su + WHERE su.socio_id = s.id AND su.revocado_at IS NULL + ) + -- sin código vivo, o con uno ya vencido + AND (i.id IS NULL OR i.expira_at <= now()) + AND (p_categoria_id IS NULL OR s.categoria_id = p_categoria_id) + -- Un socio dado de baja no necesita acceso a la app. + AND s.fecha_baja IS NULL + ) + SELECT c.id, c.nro_socio, c.apellido, c.nombre, c.categoria, c.vencido, + count(*) OVER () + FROM candidatos c + ORDER BY c.apellido, c.nombre, c.nro_socio + LIMIT least(greatest(coalesce(p_limit, 1000), 1), 1000); +END; +$$; + +REVOKE EXECUTE ON FUNCTION listar_socios_para_emision(uuid, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION listar_socios_para_emision(uuid, integer) TO authenticated; + +-- ============================================================================ +-- CANJE — sólo service_role +-- +-- Estas cuatro funciones toman parámetros que no se pueden confiar a un +-- usuario final (p_user_id, p_ip_hash). Si `authenticated` pudiera llamarlas, +-- cualquier socio ya logueado podría vincular la cuenta de otro, o limpiar su +-- propio contador de rate limit y hacer fuerza bruta sin freno. +-- +-- Por eso van revocadas de PUBLIC, anon Y authenticated: el único camino es el +-- route handler, que corre con service_role y donde vive el rate limiter. Así +-- el limiter no se puede saltear yendo directo a PostgREST. +-- +-- Ojo: Supabase concede ALL ON FUNCTIONS a anon y authenticated por default +-- privileges del schema public. Un REVOKE ... FROM PUBLIC a secas NO alcanza, +-- porque el grant de cada rol le sobrevive. Hay que nombrarlos. +-- ============================================================================ + +-- Valida sin consumir. El estado devuelto es lo que permite distinguir +-- "código inexistente" de "código vencido" SÓLO cuando el hash matcheó, o sea +-- cuando quien llama ya demostró conocer un código real. Para un hash que no +-- existe devuelve 'inexistente' y nada más: no es un oráculo. +CREATE OR REPLACE FUNCTION mobile_validar_invitacion(p_codigo_hash bytea) +RETURNS TABLE ( + estado text, -- 'valida'|'expirada'|'usada'|'revocada'|'inexistente' + socio_id uuid, + nro_socio integer, + apellido text, + nombre text +) +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path = public +AS $$ + SELECT + CASE + WHEN i.id IS NULL THEN 'inexistente' + WHEN i.revocada_at IS NOT NULL THEN 'revocada' + WHEN i.usado_at IS NOT NULL THEN 'usada' + WHEN i.expira_at <= now() THEN 'expirada' + ELSE 'valida' + END, + s.id, s.nro_socio, s.apellido, s.nombre + FROM (SELECT 1) dummy + LEFT JOIN socios_invitaciones i ON i.codigo_hash = p_codigo_hash + LEFT JOIN socios s ON s.id = i.socio_id; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_validar_invitacion(bytea) FROM PUBLIC, anon, authenticated; + +-- El canje propiamente dicho. +-- +-- El "un solo uso" vive en el UPDATE ... WHERE usado_at IS NULL ... RETURNING, +-- que es una sola sentencia y NO un SELECT seguido de un UPDATE. Con dos +-- canjes concurrentes del mismo código, el segundo espera el lock de la fila, +-- reevalúa `usado_at IS NULL` después de soltarlo, lo encuentra falso y no +-- matchea ninguna fila. +-- +-- El INSERT en socios_usuarios va en la misma transacción: si el socio ya +-- estaba vinculado, el índice parcial único rebota y el rollback deshace +-- también el consumo del código. +CREATE OR REPLACE FUNCTION mobile_canjear_invitacion(p_codigo_hash bytea, p_user_id uuid) +RETURNS TABLE (socio_id uuid, nro_socio integer, apellido text, nombre text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_inv socios_invitaciones%ROWTYPE; +BEGIN + UPDATE socios_invitaciones i + SET usado_at = now(), usado_por_user_id = p_user_id + WHERE i.codigo_hash = p_codigo_hash + AND i.usado_at IS NULL + AND i.revocada_at IS NULL + AND i.expira_at > now() + RETURNING i.* INTO v_inv; + + IF NOT FOUND THEN + RAISE EXCEPTION 'codigo_invalido'; + END IF; + + INSERT INTO socios_usuarios (user_id, socio_id, invitacion_id) + VALUES (p_user_id, v_inv.socio_id, v_inv.id); + + RETURN QUERY + SELECT s.id, s.nro_socio, s.apellido, s.nombre + FROM socios s + WHERE s.id = v_inv.socio_id; +END; +$$; + +REVOKE EXECUTE ON FUNCTION mobile_canjear_invitacion(bytea, uuid) FROM PUBLIC, anon, authenticated; + +-- Ventana deslizante de 15 minutos, 10 intentos, castigo de 1 hora. +-- Devuelve si el intento debe rechazarse y en cuántos segundos reintentar. +CREATE OR REPLACE FUNCTION registrar_intento_canje(p_ip_hash bytea) +RETURNS TABLE (bloqueado boolean, retry_after integer) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_row canje_rate_limit%ROWTYPE; +BEGIN + INSERT INTO canje_rate_limit (ip_hash, ventana_inicio, intentos) + VALUES (p_ip_hash, now(), 1) + ON CONFLICT (ip_hash) DO UPDATE + SET + -- Si la ventana venció, arranca una nueva; si no, acumula. + ventana_inicio = CASE + WHEN canje_rate_limit.bloqueado_hasta IS NOT NULL + AND canje_rate_limit.bloqueado_hasta > now() THEN canje_rate_limit.ventana_inicio + WHEN canje_rate_limit.ventana_inicio < now() - interval '15 minutes' THEN now() + ELSE canje_rate_limit.ventana_inicio + END, + intentos = CASE + WHEN canje_rate_limit.bloqueado_hasta IS NOT NULL + AND canje_rate_limit.bloqueado_hasta > now() THEN canje_rate_limit.intentos + WHEN canje_rate_limit.ventana_inicio < now() - interval '15 minutes' THEN 1 + ELSE canje_rate_limit.intentos + 1 + END + RETURNING * INTO v_row; + + -- Alcanzó el tope dentro de la ventana → castigo de 1 h. + IF v_row.intentos > 10 AND (v_row.bloqueado_hasta IS NULL OR v_row.bloqueado_hasta <= now()) THEN + UPDATE canje_rate_limit + SET bloqueado_hasta = now() + interval '1 hour' + WHERE ip_hash = p_ip_hash + RETURNING * INTO v_row; + END IF; + + IF v_row.bloqueado_hasta IS NOT NULL AND v_row.bloqueado_hasta > now() THEN + RETURN QUERY SELECT true, GREATEST(EXTRACT(EPOCH FROM (v_row.bloqueado_hasta - now()))::integer, 1); + ELSE + RETURN QUERY SELECT false, 0; + END IF; +END; +$$; + +REVOKE EXECUTE ON FUNCTION registrar_intento_canje(bytea) FROM PUBLIC, anon, authenticated; + +-- Se llama tras un canje exitoso: el que tenía un código válido no es un +-- atacante, y no queremos que una familia detrás de un NAT compartido se +-- bloquee entre sí al activar varias cuentas seguidas. +CREATE OR REPLACE FUNCTION limpiar_intento_canje(p_ip_hash bytea) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + DELETE FROM canje_rate_limit WHERE ip_hash = p_ip_hash; +$$; + +REVOKE EXECUTE ON FUNCTION limpiar_intento_canje(bytea) FROM PUBLIC, anon, authenticated; From f716ced0c6930e8470dd5f0bb84f3a294e8096a8 Mon Sep 17 00:00:00 2001 From: Diego Date: Fri, 14 Aug 2026 07:14:42 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(P12.1):=20p=C3=A9rdida=20de=20c=C3=B3di?= =?UTF-8?q?gos=20en=20emisi=C3=B3n=20masiva=20y=20carrera=20en=20los=20tri?= =?UTF-8?q?ggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hallazgos del segundo review (subagente code-reviewer) sobre 13adcc2. CRÍTICO — los códigos de una emisión masiva se perdían. `emitir()` guardaba los códigos en claro con setEmitidos(res) y a continuación llamaba a previsualizar(), cuya primera línea era setEmitidos([]): el último write ganaba. Como la base guarda sólo los hashes, la tanda entera quedaba irrecuperable, con un toast "120 código(s) emitido(s)" al lado de un botón "Descargar Excel (0)" deshabilitado. La limpieza pasa a ocurrir al cambiar de lote, no al emitir. ALTO — la invariante socio ≠ staff no resistía la concurrencia. Los triggers hacían EXISTS sobre la otra tabla sin lock, así que bajo READ COMMITTED dos transacciones simultáneas no se veían y ambas commiteaban, dejando una cuenta que era socio Y staff: con `socios:leer` lee el padrón entero por PostgREST directo, esquivando todas las funciones mobile_*. Se cierra con pg_advisory_xact_lock sobre el user_id en ambos triggers, verificado con dos sesiones psql concurrentes. ALTO — el filtro de cuentas de socios en Seguridad reintroducía el truncamiento de 1000 filas que el mismo commit acababa de arreglar para listUsers: a partir de la cuenta 1.001 volvían a aparecer mezcladas con el staff. Ahora usa fetchAllRows y sólo considera vínculos vivos, para que una cuenta desvinculada no quede invisible e inadministrable. Menores: recuento correcto al paginar más allá del final en getEstadoAppMovil; dedup de socioIds (un id repetido abortaba el lote entero por el índice parcial); purga oportunista de canje_rate_limit, que sólo se limpiaba en el canje exitoso; search_path con pg_temp en las 20 funciones DEFINER; confirmación antes de reemitir un código vigente, que revoca el que el socio quizá ya tiene; se borra igualSeguro (código muerto); y se corrige el comentario del ban, que decía invalidar el access token vigente cuando lo que corta el acceso es la fila revocada. Se documenta sin vueltas que el fallback del rate limiter sin IP confiable no protege contra fuerza bruta: se elige igual porque la alternativa (bucket global) permite bloquear las activaciones de todos los socios. --- CHANGELOG.md | 3 + .../(dashboard)/security/usuarios/actions.ts | 21 ++++-- .../(dashboard)/socios/app-movil/actions.ts | 51 +++++++++++--- .../socios/app-movil/emision-masiva/page.tsx | 18 ++++- src/app/(dashboard)/socios/app-movil/page.tsx | 22 +++++- src/lib/api/rate-limit.ts | 14 ++-- src/lib/invitaciones.ts | 10 +-- .../20260813000001_app_movil_socios.sql | 67 +++++++++++++------ 8 files changed, 155 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f88bef..39137b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ Remediación del export de advisories de Sentinello del 2026-08-04 (41 hallazgos - **El grupo familiar falla cerrado.** Sólo el titular (`grupos_familiares.titular_id`) ve las cuotas del grupo; si el grupo no tiene titular designado, no lo ve nadie. En los datos migrados puede haber grupos sin titular y es tentador inferirlo (el más antiguo, el de menor `nro_socio`), pero eso sería inventar una regla de autorización cuyo costo de error es mostrarle a alguien la deuda de un tercero. Para que no se vuelva un ticket irresoluble, `/socios/grupos-familiares` ahora avisa cuántos grupos están así y permite filtrarlos. De los demás miembros se expone sólo nombre, categoría y deuda — nunca DNI, fecha de nacimiento ni localidad. - **El middleware redirigía `/api` a `/login` con un 307.** El matcher no excluía `/api` y el guard de sesión es incondicional salvo para `/login`, así que cualquier endpoint le habría devuelto el HTML del login a un cliente que espera JSON — indistinguible de un bug del endpoint. Se arregla en dos lugares a propósito: la exclusión en el matcher (que además ahorra un round-trip a GoTrue por request) y un guard al inicio de `updateSession`, que sobrevive a que alguien edite el matcher sin acordarse de por qué estaba así. - **Rate limit en tabla, no en memoria.** Vercel corre lambdas sin estado compartido: un contador de módulo arrancaría vacío en cada invocación. 10 intentos por IP cada 15 minutos, 1 hora de castigo. La IP se toma de `x-vercel-forwarded-for` y **no** de `x-forwarded-for` a secas, que el cliente puede anteponer para rotar identidad en cada request y volver el limiter decorativo. Las RPCs de canje, validación y rate-limit están revocadas de `anon` **y** de `authenticated`, así que el único camino es el route handler y el limiter no se puede saltear yendo directo a PostgREST. + - **Un segundo review encontró un bug de pérdida de datos que el primero no vio.** En la emisión masiva, `emitir()` guardaba los códigos en claro con `setEmitidos(res)` y acto seguido llamaba a `previsualizar()`, cuya primera línea era `setEmitidos([])`: el último write ganaba y los borraba. Como la base guarda **sólo los hashes**, los códigos de la tanda entera quedaban irrecuperables, con un toast diciendo "120 código(s) emitido(s)" al lado de un botón "Descargar Excel (0)" deshabilitado — y la única salida era reemitir, revocando de nuevo los que el operador ya hubiera repartido. + - **La invariante socio ≠ staff no resistía la concurrencia.** Los dos triggers hacían un `EXISTS` sobre la otra tabla sin ningún lock, así que bajo READ COMMITTED dos transacciones simultáneas no se veían y ambas commiteaban: quedaba una cuenta que era socio **y** staff, o sea con `socios:leer` — lectura del padrón entero por PostgREST directo, esquivando todas las funciones `mobile_*`. Se cierra con `pg_advisory_xact_lock(hashtextextended(user_id, 0))` como primera sentencia de ambos triggers, verificado con dos sesiones psql concurrentes (la segunda ahora bloquea y falla). No era alcanzable desde la app, pero es la invariante que la migración declara como su mitigación más importante y cualquier script con `service_role` la volvía alcanzable. + - El filtro de cuentas de socios en Seguridad → Usuarios reintroducía el truncamiento de 1000 filas de PostgREST que el propio cambio acababa de arreglar para `listUsers`: a partir de la cuenta 1.001 las cuentas de socios volvían a aparecer mezcladas con el staff. Ahora usa `fetchAllRows` y filtra sólo vínculos vivos, para que una cuenta desvinculada no quede invisible e inadministrable. - Los handlers **nunca** propagan mensajes crudos de Postgres (el patrón `throw new Error(error.message)` de los ~48 `actions.ts`): las RPCs levantan identificadores snake_case que son contrato de API y los traduce `src/lib/api/rpc-errors.ts`; lo no mapeado es un 500 genérico con `X-Request-Id` para correlacionar en los logs. `admin.ts` gana `import "server-only"`. Documentación completa en `docs/API_MOBILE.md`. - **Precio diferenciado para socios y no socios en los ítems de venta** (migración `20260812000001`). `items_ventas` pasa de un precio a dos: `precio` (que ahora significa explícitamente *tarifa de socio*) y `precio_no_socio`. El toggle **Socio | No Socio** que ya existía en el POS pasa de sólo cambiar qué datos del comprador se piden a **determinar cuánto se cobra**. diff --git a/src/app/(dashboard)/security/usuarios/actions.ts b/src/app/(dashboard)/security/usuarios/actions.ts index 4beda5d..6d3f617 100644 --- a/src/app/(dashboard)/security/usuarios/actions.ts +++ b/src/app/(dashboard)/security/usuarios/actions.ts @@ -2,6 +2,7 @@ import { createClient } from "@/lib/supabase/server"; import { createAdminClient } from "@/lib/supabase/admin"; +import { fetchAllRows } from "@/lib/supabase/fetch-all-rows"; import { isAdmin } from "@/lib/permissions"; import { revalidatePath } from "next/cache"; import type { UsuarioSistema } from "@/types/security"; @@ -52,10 +53,22 @@ export async function getUsuarios(): Promise { // que en esta pantalla sólo serían ruido. Se administran desde // /socios/app-movil. Se excluyen también las revocadas: siguen siendo cuentas // de socios, no de staff. - const { data: cuentasSocios } = await admin - .from("socios_usuarios") - .select("user_id"); - const esDeSocio = new Set((cuentasSocios ?? []).map((c) => c.user_id)); + // fetchAllRows y no un .select() pelado: PostgREST corta en 1000 filas en + // silencio, y acá eso reintroduce exactamente el problema que la paginación + // de arriba acaba de resolver — a partir de la cuenta de socio número 1.001 + // el filtro dejaría de reconocerlas y volverían a aparecer en el listado. + const cuentasSocios = await fetchAllRows<{ user_id: string }>((desde, hasta) => + admin + .from("socios_usuarios") + .select("user_id") + // Sólo los vínculos vivos: una cuenta ya desvinculada dejó de ser de la + // app móvil, y ocultarla acá la volvería invisible e inadministrable + // (no habría forma de borrarla desde ninguna pantalla). + .is("revocado_at", null) + .order("user_id") + .range(desde, hasta), + ); + const esDeSocio = new Set(cuentasSocios.map((c) => c.user_id)); // Get all user-role assignments const { data: userRoles } = await admin diff --git a/src/app/(dashboard)/socios/app-movil/actions.ts b/src/app/(dashboard)/socios/app-movil/actions.ts index b6bda6e..545d0c6 100644 --- a/src/app/(dashboard)/socios/app-movil/actions.ts +++ b/src/app/(dashboard)/socios/app-movil/actions.ts @@ -55,10 +55,37 @@ export async function getEstadoAppMovil(params: { delete copia.total_filas; return copia as FilaAppMovil; }), - total: filas.length > 0 ? Number(filas[0].total_filas) : 0, + // Página vacía no significa "no hay nada": puede ser que el operador se + // pasó del final, y ahí se pierde el count(*) OVER (). Devolver 0 haría + // colapsar el paginador del DataTable sin forma de volver a la página 1. + total: + filas.length > 0 + ? Number(filas[0].total_filas) + : page > 1 + ? await contarEstadoAppMovil(supabase, params) + : 0, }; } +/** + * Recuento de respaldo para cuando la página pedida cayó más allá del final y + * la RPC no devolvió ninguna fila de la cual leer `total_filas`. Cuesta una + * consulta extra, y sólo en ese caso. + */ +async function contarEstadoAppMovil( + supabase: Awaited>, + params: { search?: string; estado?: string }, +): Promise { + const { data } = await supabase.rpc("listar_estado_app_movil", { + p_search: params.search?.trim() || null, + p_estado: params.estado || "todos", + p_limit: 1, + p_offset: 0, + }); + const primera = (data ?? [])[0] as { total_filas?: number } | undefined; + return primera ? Number(primera.total_filas ?? 0) : 0; +} + /** * Emite un código para un socio y lo devuelve EN CLARO, una sola vez. * @@ -114,8 +141,12 @@ export async function emitirCodigosMasivo( socioIds: string[], dias = 14, ): Promise { - if (socioIds.length === 0) return []; - if (socioIds.length > MAX_LOTE) { + // Sin dedup, un id repetido en el lote hace saltar el índice parcial + // ux_socios_invitaciones_socio_viva y aborta la tanda ENTERA, no sólo esa fila. + const ids = [...new Set(socioIds)]; + + if (ids.length === 0) return []; + if (ids.length > MAX_LOTE) { throw new Error( `El lote no puede superar los ${MAX_LOTE} socios. Filtre por categoría y emita por tandas.`, ); @@ -126,7 +157,7 @@ export async function emitirCodigosMasivo( // El código en claro se conserva sólo en memoria de este request, indexado // por socio, para poder devolverlo junto con el nombre. A la base va el hash. const claros = new Map(); - const items = socioIds.map((socioId) => { + const items = ids.map((socioId) => { const codigo = generarCodigo(); claros.set(socioId, codigo); return { @@ -183,10 +214,14 @@ export async function revocarCodigo(socioId: string): Promise { /** * Desvincula la cuenta de un socio y además la banea en Auth. * - * Las dos cosas hacen falta: revocar la fila de `socios_usuarios` hace que las - * RPCs devuelvan vacío de inmediato, pero el access token que el teléfono ya - * tiene sigue siendo un token válido del proyecto hasta que expire (1 h). El - * ban lo corta ahí mismo y evita que pueda renovarlo. + * Lo que corta el acceso a los datos en el acto es la fila revocada: las RPCs + * `mobile_*` filtran por `revocado_at IS NULL`, así que el token deja de servir + * para leer nada aunque siga siendo criptográficamente válido. + * + * El ban cubre lo otro: GoTrue no valida los JWT ya emitidos contra la base, o + * sea que el access token en el teléfono sigue siendo un token válido del + * proyecto hasta que expire (1 h). El ban no lo invalida — impide el login y + * el refresh, que es lo que evita que la cuenta se renueve indefinidamente. */ export async function desvincularCuenta(socioId: string): Promise { const supabase = await createClient(); diff --git a/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx index 881089b..bd60755 100644 --- a/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx +++ b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx @@ -38,9 +38,15 @@ export default function EmisionMasivaPage() { .catch(() => toast.error("No se pudieron cargar las categorías")); }, []); + // OJO: esta función NO puede limpiar `emitidos`. `emitir()` la llama al + // terminar para refrescar el conteo de candidatos, y si acá se hiciera + // setEmitidos([]) el último write ganaría y borraría los códigos recién + // emitidos — que sólo existen en claro en ese estado de React, porque la + // base guarda únicamente sus hashes. Serían irrecuperables: la única salida + // sería reemitir, revocando otra vez los que el operador ya repartió. + // El limpiado ocurre al cambiar de lote (ver onValueChange del Select). const previsualizar = useCallback(async () => { setCargando(true); - setEmitidos([]); try { const res = await getSociosParaEmision( categoriaId === "todas" ? undefined : categoriaId, @@ -109,7 +115,15 @@ export default function EmisionMasivaPage() {
- { + // Cambiar de lote sí descarta los códigos mostrados: pasan a + // ser de otra tanda y dejarlos en pantalla confundiría. + setEmitidos([]); + setCategoriaId(v); + }} + > diff --git a/src/app/(dashboard)/socios/app-movil/page.tsx b/src/app/(dashboard)/socios/app-movil/page.tsx index d32adba..88094c9 100644 --- a/src/app/(dashboard)/socios/app-movil/page.tsx +++ b/src/app/(dashboard)/socios/app-movil/page.tsx @@ -108,6 +108,17 @@ export default function AppMovilPage() { } } + function pedirReemitir(fila: FilaAppMovil) { + setConfirmacion({ + titulo: "¿Reemitir el código?", + descripcion: `${fila.apellido}, ${fila.nombre} ya tiene un código vigente. Emitir uno nuevo invalida el anterior: si ya se lo entregó, va a dejar de funcionar.`, + accion: async () => { + const codigo = await emitirCodigo(fila.socio_id); + setEmitido(codigo); + }, + }); + } + function pedirRevocar(fila: FilaAppMovil) { setConfirmacion({ titulo: "¿Revocar el código?", @@ -212,7 +223,16 @@ export default function AppMovilPage() { variant="outline" size="sm" disabled={procesando} - onClick={() => handleEmitir(f)} + onClick={() => { + // Reemitir revoca el código anterior. Si está vigente, el + // socio puede tenerlo en la mano y dejaría de funcionar sin + // que nadie se entere hasta que lo intente usar. + if (f.estado === "codigo_vigente") { + pedirReemitir(f); + } else { + handleEmitir(f); + } + }} > {f.estado === "sin_codigo" ? "Emitir" : "Reemitir"} diff --git a/src/lib/api/rate-limit.ts b/src/lib/api/rate-limit.ts index f0c6a43..4166f1c 100644 --- a/src/lib/api/rate-limit.ts +++ b/src/lib/api/rate-limit.ts @@ -27,12 +27,16 @@ export type ResultadoLimite = * un bucket fijo ("desconocida"), pero eso hace que 11 intentos de cualquiera * bloqueen las activaciones de TODOS los socios durante una hora — un DoS * trivial contra la funcionalidad entera. Se usa entonces una clave derivada - * del código intentado: frena a alguien machacando un mismo código y, sobre - * todo, no deja que un request afecte a los demás. + * del código intentado. * - * Es una degradación consciente: sin un identificador de cliente confiable no - * se puede limitar por cliente, y punto. En Vercel —que es el despliegue real— - * la cabecera siempre está, así que este camino es el de desarrollo local. + * SIN VUELTAS: en ese modo NO hay protección contra fuerza bruta. Un atacante + * que prueba un código distinto por request cae en un bucket distinto cada vez + * y nunca se bloquea. Lo único que frena es machacar un mismo código. Se elige + * porque la alternativa (bucket global) es un DoS seguro contra todos los + * socios, mientras que esto sólo pierde protección en un despliegue mal + * configurado. En Vercel —el despliegue real— la cabecera siempre está, así + * que este camino es el de desarrollo local; si alguna vez se despliega fuera + * de Vercel, hay que poner un proxy que setee la IP antes de exponer la API. */ export async function chequearLimite( admin: SupabaseClient, diff --git a/src/lib/invitaciones.ts b/src/lib/invitaciones.ts index eafee16..ce221d9 100644 --- a/src/lib/invitaciones.ts +++ b/src/lib/invitaciones.ts @@ -1,5 +1,5 @@ import "server-only"; -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; /** * Códigos de invitación de la app móvil: generación, normalización y hash. @@ -133,11 +133,3 @@ export function ipConfiable(request: Request): string | null { null ); } - -/** Comparación en tiempo constante, para chequeos de igualdad sobre secretos. */ -export function igualSeguro(a: string, b: string): boolean { - const ba = Buffer.from(a); - const bb = Buffer.from(b); - if (ba.length !== bb.length) return false; - return timingSafeEqual(ba, bb); -} diff --git a/supabase/migrations/20260813000001_app_movil_socios.sql b/supabase/migrations/20260813000001_app_movil_socios.sql index 071c289..f78e99d 100644 --- a/supabase/migrations/20260813000001_app_movil_socios.sql +++ b/supabase/migrations/20260813000001_app_movil_socios.sql @@ -177,9 +177,19 @@ CREATE POLICY "select_socios_usuarios" ON socios_usuarios FOR SELECT TO authenti CREATE OR REPLACE FUNCTION socios_usuarios_excluye_staff() RETURNS trigger LANGUAGE plpgsql -SET search_path = public +SET search_path = public, pg_temp AS $$ BEGIN + -- El lock serializa este chequeo contra el del trigger espejo para el MISMO + -- usuario. Sin él, bajo READ COMMITTED dos transacciones concurrentes (una + -- insertando el vínculo, otra insertando el rol) no se ven entre sí: los dos + -- EXISTS dan falso, ambas commitean, y queda una cuenta que es socio Y staff + -- a la vez — o sea con `socios:leer`, que es lectura del padrón entero por + -- PostgREST directo, esquivando todas las funciones mobile_*. Es la + -- invariante que esta migración declara como su mitigación más importante, + -- así que no puede depender de que nadie escriba las dos tablas a la vez. + PERFORM pg_advisory_xact_lock(hashtextextended(NEW.user_id::text, 0)); + -- Sólo aplica a vínculos vivos: revocar (poner revocado_at) siempre se permite. IF NEW.revocado_at IS NULL AND EXISTS (SELECT 1 FROM usuarios_roles ur WHERE ur.user_id = NEW.user_id) THEN @@ -200,9 +210,14 @@ CREATE TRIGGER trg_socios_usuarios_excluye_staff CREATE OR REPLACE FUNCTION usuarios_roles_excluye_socios() RETURNS trigger LANGUAGE plpgsql -SET search_path = public +SET search_path = public, pg_temp AS $$ BEGIN + -- Mismo lock que en socios_usuarios_excluye_staff, sobre la misma clave: es + -- lo que hace que los dos chequeos no puedan correr en paralelo para un + -- mismo usuario. Ver el comentario largo allá. + PERFORM pg_advisory_xact_lock(hashtextextended(NEW.user_id::text, 0)); + IF EXISTS ( SELECT 1 FROM socios_usuarios su WHERE su.user_id = NEW.user_id AND su.revocado_at IS NULL @@ -229,7 +244,7 @@ RETURNS uuid LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT su.socio_id FROM socios_usuarios su @@ -252,7 +267,7 @@ RETURNS TABLE (user_id uuid, socio_id uuid, nro_socio integer) LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT auth.uid(), su.socio_id, s.nro_socio FROM socios_usuarios su @@ -299,7 +314,7 @@ RETURNS TABLE ( LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT s.id, @@ -353,7 +368,7 @@ RETURNS TABLE ( LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT c.id, c.periodo, c.monto, c.pagada, c.fecha_pago, @@ -394,7 +409,7 @@ RETURNS TABLE ( LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT count(*) FILTER (WHERE NOT c.pagada)::integer, @@ -430,7 +445,7 @@ RETURNS TABLE ( LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT v.id, v.fecha, v.total, mc.nombre, d.nombre, @@ -466,7 +481,7 @@ RETURNS jsonb LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT jsonb_build_object( 'id', v.id, @@ -525,7 +540,7 @@ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_socio uuid := mobile_socio_actual(); @@ -571,7 +586,7 @@ RETURNS TABLE ( LANGUAGE plpgsql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_grupo uuid := mobile_mi_grupo_titular(); @@ -620,7 +635,7 @@ RETURNS TABLE ( LANGUAGE plpgsql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_grupo uuid := mobile_mi_grupo_titular(); @@ -666,7 +681,7 @@ CREATE OR REPLACE FUNCTION emitir_invitacion_socio( RETURNS TABLE (invitacion_id uuid, expira_at timestamptz) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_user uuid := auth.uid(); @@ -720,7 +735,7 @@ CREATE OR REPLACE FUNCTION emitir_invitaciones_socios( RETURNS TABLE (socio_id uuid, invitacion_id uuid, expira_at timestamptz) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_user uuid := auth.uid(); @@ -776,7 +791,7 @@ CREATE OR REPLACE FUNCTION revocar_invitacion_socio(p_socio_id uuid) RETURNS integer LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_user uuid := auth.uid(); @@ -814,7 +829,7 @@ CREATE OR REPLACE FUNCTION desvincular_cuenta_socio(p_socio_id uuid) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_user uuid := auth.uid(); @@ -868,7 +883,7 @@ RETURNS TABLE ( LANGUAGE plpgsql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ BEGIN IF NOT permiso_modulo_todos_los_roles('socios', 'leer') THEN @@ -948,7 +963,7 @@ RETURNS TABLE ( LANGUAGE plpgsql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ BEGIN IF NOT permiso_modulo_todos_los_roles('socios', 'escribir') THEN @@ -1018,7 +1033,7 @@ RETURNS TABLE ( LANGUAGE sql SECURITY DEFINER STABLE -SET search_path = public +SET search_path = public, pg_temp AS $$ SELECT CASE @@ -1051,7 +1066,7 @@ CREATE OR REPLACE FUNCTION mobile_canjear_invitacion(p_codigo_hash bytea, p_user RETURNS TABLE (socio_id uuid, nro_socio integer, apellido text, nombre text) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_inv socios_invitaciones%ROWTYPE; @@ -1086,11 +1101,19 @@ CREATE OR REPLACE FUNCTION registrar_intento_canje(p_ip_hash bytea) RETURNS TABLE (bloqueado boolean, retry_after integer) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DECLARE v_row canje_rate_limit%ROWTYPE; BEGIN + -- Purga oportunista: sin esto la tabla crece de forma monótona (sólo se + -- borraba una fila en el canje exitoso, que es el caso raro). Una fila cuya + -- ventana venció hace más de un día y que no está bloqueada ya no aporta + -- nada. Va acá y no en un cron porque el proyecto no tiene pg_cron activo. + DELETE FROM canje_rate_limit + WHERE ventana_inicio < now() - interval '1 day' + AND (bloqueado_hasta IS NULL OR bloqueado_hasta <= now()); + INSERT INTO canje_rate_limit (ip_hash, ventana_inicio, intentos) VALUES (p_ip_hash, now(), 1) ON CONFLICT (ip_hash) DO UPDATE @@ -1135,7 +1158,7 @@ CREATE OR REPLACE FUNCTION limpiar_intento_canje(p_ip_hash bytea) RETURNS void LANGUAGE sql SECURITY DEFINER -SET search_path = public +SET search_path = public, pg_temp AS $$ DELETE FROM canje_rate_limit WHERE ip_hash = p_ip_hash; $$; From ec81609bdc19287e1332af3a270f4f46e638d14b Mon Sep 17 00:00:00 2001 From: Diego Date: Fri, 14 Aug 2026 07:25:49 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(P12.1):=20la=20emisi=C3=B3n=20masiva=20?= =?UTF-8?q?perd=C3=ADa=20los=20c=C3=B3digos=20de=20las=20tandas=20anterior?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segunda vuelta de verificación sobre f716ced: el fix del crítico estaba incompleto. `emitir()` seguía haciendo setEmitidos(res) con REEMPLAZO. Con más de 1.000 candidatos —o sea la primera emisión real, con ~8.400 socios— previsualizar() repuebla la lista con la tanda siguiente y rehabilita el botón, y el banner le pide explícitamente al operador que repita la operación. El segundo click pisaba los códigos del primero: 1.000 códigos vivos en la base, de los que sólo se guarda el hash, y nadie los tiene. El mismo modo de falla que el bug original, con un click de por medio. Ahora acumula: la lista es "todo lo que emitiste y no descargaste todavía", y el Excel se baja una sola vez al final. También se saca el setEmitidos([]) del onValueChange de la categoría, que convertía un click en un Select en una destrucción irreversible y silenciosa —la misma pérdida de datos por otra puerta—. El descarte pasa a un botón explícito "Limpiar lista" con confirmación. Seguridad → Usuarios marca con un badge las ex-cuentas de socios. Mostrarlas era necesario (ocultarlas las dejaba imposibles de borrar), pero el trigger permite darles un rol del ERP y su email lo eligió el socio al activar la app, sin verificación y fuera del control del club: el admin tiene que poder distinguirlas de una cuenta de staff antes de asignarles nada. La purga de canje_rate_limit pasa a correr en 1 de cada 100 intentos. Corriendo en cada request, bajo una ráfaga de fuerza bruta todos los requests hacían seq scan y tomaban row locks sobre las mismas filas basura, serializándose entre sí justo cuando menos conviene. Se documenta que los advisory locks de los triggers no pueden dar deadlock hoy (un user_id por transacción), pero que cualquier operación en lote futura sobre esas tablas tiene que ordenar por user_id. Verificado: `supabase migration list` confirma que el remoto todavía NO tiene 20260813000001, así que editarla en el lugar es correcto y no hace falta una migración de fix. Carrera de los triggers reprobada en los dos órdenes. --- .../(dashboard)/security/usuarios/actions.ts | 30 ++++++++++--- .../(dashboard)/security/usuarios/page.tsx | 12 ++++- .../socios/app-movil/emision-masiva/page.tsx | 44 ++++++++++++++----- src/types/security.ts | 7 +++ .../20260813000001_app_movil_socios.sql | 21 +++++++-- 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/security/usuarios/actions.ts b/src/app/(dashboard)/security/usuarios/actions.ts index 6d3f617..bd54dc5 100644 --- a/src/app/(dashboard)/security/usuarios/actions.ts +++ b/src/app/(dashboard)/security/usuarios/actions.ts @@ -57,18 +57,33 @@ export async function getUsuarios(): Promise { // silencio, y acá eso reintroduce exactamente el problema que la paginación // de arriba acaba de resolver — a partir de la cuenta de socio número 1.001 // el filtro dejaría de reconocerlas y volverían a aparecer en el listado. - const cuentasSocios = await fetchAllRows<{ user_id: string }>((desde, hasta) => + const cuentasSocios = await fetchAllRows<{ + user_id: string; + revocado_at: string | null; + }>((desde, hasta) => admin .from("socios_usuarios") - .select("user_id") - // Sólo los vínculos vivos: una cuenta ya desvinculada dejó de ser de la - // app móvil, y ocultarla acá la volvería invisible e inadministrable - // (no habría forma de borrarla desde ninguna pantalla). - .is("revocado_at", null) + .select("user_id, revocado_at") .order("user_id") .range(desde, hasta), ); - const esDeSocio = new Set(cuentasSocios.map((c) => c.user_id)); + + // Las cuentas con vínculo VIVO se ocultan: no son usuarios del ERP y no + // pueden tener rol (lo impide trg_usuarios_roles_excluye_socios). + const esDeSocio = new Set( + cuentasSocios.filter((c) => c.revocado_at === null).map((c) => c.user_id), + ); + + // Las desvinculadas sí se muestran —ocultarlas las dejaba invisibles e + // imposibles de borrar— pero marcadas. El trigger permite darles un rol del + // ERP, y su email lo eligió el socio al activar la app, sin verificación y + // fuera del control del club: el admin tiene que poder distinguirlas de una + // cuenta de staff antes de asignarle nada. + const exSocio = new Set( + cuentasSocios + .filter((c) => c.revocado_at !== null && !esDeSocio.has(c.user_id)) + .map((c) => c.user_id), + ); // Get all user-role assignments const { data: userRoles } = await admin @@ -104,6 +119,7 @@ export async function getUsuarios(): Promise { : null, rol_id: roleInfo?.rol_id ?? null, rol_nombre: roleInfo?.rol_nombre ?? null, + ex_cuenta_socio: exSocio.has(u.id), }; }); } diff --git a/src/app/(dashboard)/security/usuarios/page.tsx b/src/app/(dashboard)/security/usuarios/page.tsx index e1a819d..0fb0fe8 100644 --- a/src/app/(dashboard)/security/usuarios/page.tsx +++ b/src/app/(dashboard)/security/usuarios/page.tsx @@ -21,7 +21,17 @@ const columns: ColumnDef[] = [ accessorKey: "email", header: "Email", cell: ({ row }) => ( - {row.original.email} +
+ {row.original.email} + {/* El email de una ex-cuenta de socio lo eligió el socio al activar la + app móvil, sin verificación: no es una cuenta creada por el club. + Se marca para que no se le asigne un rol del ERP por confusión. */} + {row.original.ex_cuenta_socio ? ( + + Ex-cuenta de socio + + ) : null} +
), }, { diff --git a/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx index bd60755..6cf190a 100644 --- a/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx +++ b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx @@ -68,7 +68,13 @@ export default function EmisionMasivaPage() { setEmitiendo(true); try { const res = await emitirCodigosMasivo(candidatos.map((c) => c.socio_id)); - setEmitidos(res); + // ACUMULA, no reemplaza. Con más de MAX_LOTE candidatos el banner le pide + // al operador que repita la operación, y `previsualizar()` rehabilita el + // botón con la tanda siguiente: si esto reemplazara, el segundo click + // borraría los códigos del primero — que ya están vivos en la base y de + // los que sólo se guarda el hash. La lista es "todo lo que emitiste y + // todavía no descargaste", y el Excel se baja una sola vez al final. + setEmitidos((prev) => [...prev, ...res]); if (res.length === 0) { toast.info("No se emitió ningún código: los socios del lote ya tienen cuenta."); } else { @@ -115,15 +121,12 @@ export default function EmisionMasivaPage() {
- los destruya en silencio es la misma pérdida de + datos por otra puerta. Se acumulan y se descartan sólo con el + botón explícito de abajo. */} +