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..39137b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ 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. + - **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**. - **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..bd54dc5 100644 --- a/src/app/(dashboard)/security/usuarios/actions.ts +++ b/src/app/(dashboard)/security/usuarios/actions.ts @@ -2,9 +2,11 @@ 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"; +import type { User } from "@supabase/supabase-js"; async function requireAdmin() { const supabase = await createClient(); @@ -30,11 +32,58 @@ 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. + // 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; + revocado_at: string | null; + }>((desde, hasta) => + admin + .from("socios_usuarios") + .select("user_id, revocado_at") + .order("user_id") + .range(desde, hasta), + ); + + // 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 @@ -54,7 +103,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, @@ -68,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/actions.ts b/src/app/(dashboard)/socios/app-movil/actions.ts new file mode 100644 index 0000000..545d0c6 --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/actions.ts @@ -0,0 +1,322 @@ +"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; + }), + // 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. + * + * 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 { + // 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.`, + ); + } + + 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 = ids.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. + * + * 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(); + + 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..76da6e4 --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/emision-masiva/page.tsx @@ -0,0 +1,284 @@ +"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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +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); + const [confirmarLimpiar, setConfirmarLimpiar] = 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")); + }, []); + + // 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); + 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)); + // 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 { + 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 + + +
+ {/* Cambiar de categoría NO descarta los códigos ya emitidos: son + códigos reales que nadie descargó todavía, y hacer que un click + en un + + + + + Todas las categorías + {categorias.map((c) => ( + + {c.nombre} + + ))} + + + +
+ +

+ {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 ? ( + + ) : null} +
+ + {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 o recarga habrá + que reemitirlos. La lista acumula todas las tandas emitidas + hasta que descargue o la limpie. +

+
+
+ + + + + + + + + + + {emitidos.map((e) => ( + + + + + + + ))} + +
NroSocioCódigoVence
{e.nro_socio} + {e.apellido}, {e.nombre} + {e.codigo}{formatDate(e.expira_at)}
+
+ + ) : null} +
+
+ + + + + + ¿Descartar {emitidos.length} código(s) de la pantalla? + + + Los códigos siguen activos para los socios, pero no vas a poder + volver a verlos ni descargarlos: para entregarlos habría que + reemitirlos, lo que invalida estos. Si todavía no bajaste el + Excel, hacelo antes. + + + + Cancelar + setEmitidos([])}> + Descartar + + + + +
+ ); +} 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..88094c9 --- /dev/null +++ b/src/app/(dashboard)/socios/app-movil/page.tsx @@ -0,0 +1,342 @@ +"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 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?", + 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..4166f1c --- /dev/null +++ b/src/lib/api/rate-limit.ts @@ -0,0 +1,98 @@ +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. + * + * 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, + 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..ce221d9 --- /dev/null +++ b/src/lib/invitaciones.ts @@ -0,0 +1,135 @@ +import "server-only"; +import { createHash, randomBytes } 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 + ); +} 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/src/types/security.ts b/src/types/security.ts index a50dfda..ce0e013 100644 --- a/src/types/security.ts +++ b/src/types/security.ts @@ -51,6 +51,13 @@ export interface UsuarioSistema { banned_until: string | null; rol_id: string | null; rol_nombre: string | null; + /** + * Cuenta que estuvo vinculada a un socio de la app móvil y fue desvinculada. + * Su email lo eligió el socio al activar la app, sin verificación y fuera del + * control del club, así que conviene distinguirla de una cuenta de staff + * antes de asignarle un rol del ERP. + */ + ex_cuenta_socio?: boolean; } export interface UserPermissions { 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..0f63df4 --- /dev/null +++ b/supabase/migrations/20260813000001_app_movil_socios.sql @@ -0,0 +1,1181 @@ +-- ============================================================================ +-- 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, 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. + -- + -- Hoy no puede haber deadlock: todos los caminos (mobile_canjear_invitacion, + -- updateUsuarioRole) escriben un solo user_id por transacción. Si alguna vez + -- se agrega una operación en LOTE sobre estas tablas —asignación masiva de + -- roles, vinculación en tanda— hay que ordenarla por user_id, o dos lotes + -- tomando los locks en orden inverso se traban entre sí. + 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 + 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, 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 + ) 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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, pg_temp +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, 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. + -- + -- El random() la saca del camino caliente. Sin él corre en CADA request, + -- incluidos los de una ráfaga de fuerza bruta: como no hay índice por + -- ventana_inicio, cada uno hace un seq scan y todos toman row locks sobre las + -- mismas filas basura, serializándose entre sí — contención autoinfligida + -- justo en el momento en que menos conviene. A 1 de cada 100 intentos alcanza + -- de sobra para que la tabla no crezca. + IF random() < 0.01 THEN + DELETE FROM canje_rate_limit + WHERE ventana_inicio < now() - interval '1 day' + AND (bloqueado_hasta IS NULL OR bloqueado_hasta <= now()); + END IF; + + 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, pg_temp +AS $$ + DELETE FROM canje_rate_limit WHERE ip_hash = p_ip_hash; +$$; + +REVOKE EXECUTE ON FUNCTION limpiar_intento_canje(bytea) FROM PUBLIC, anon, authenticated;