# PROPUESTA — Documento de identificación del invitado (Portal CL032 ↔ Tlalpan)

> **Estado:** propuesta de diseño, pendiente de aprobación.
> **Alcance:** cómo viaja la fotografía de identificación que el socio sube en el Portal Público hasta el servidor de Tlalpan, y qué pasa con ella después.
> **Depende de:** [`PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md`](PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md) — esto es su **fase 11**, que quedó declarada como segunda entrega.
> **Proyectos:** `CL001SICAMV2` (Tlalpan) y `CL032BACK` (Portal Público).

---

## 1. Punto de partida — qué existe HOY, verificado en código

No hay que inventar el canal: ya está construido y firmado. Falta **el disparador y el archivo**.

| Pieza | Dónde | Estado |
|---|---|:---:|
| Subida del socio: `required\|file\|mimes:jpeg,jpg,png,webp,pdf\|max:12288` | Portal · `GuardarFormRequest:63` | ✅ PDF desde el 13-ago-2026 |
| Almacenamiento privado `solicitudinvitado/{idSolicitud}/{idInvitado}.{ext}` (disco `local`) | Portal · `InvitadoController:109` | ✅ |
| Endpoint `GET /invitados/transferencia/documento/{idInvitado}` con HMAC + allowlist + 404 uniforme | Portal · `TransferenciaController:209` | ✅ |
| La referencia viaja en el lote: `documento: "/invitados/transferencia/documento/31"` | Portal · `PendienteResource:78` | ✅ |
| Columna que guarda esa referencia | Tlalpan · `inv_solicitudinvitado.solicitudInvitadoDocumento` | ✅ |
| Método de descarga firmado | Tlalpan · `ApiPortalInvitados::obtenerDocumentoInvitado()` | ✅ |
| **Alguien que lo llame** | — | ❌ |
| **Dónde guardar el archivo en Tlalpan** | — | ❌ |

**Conclusión:** el conducto está hecho y probado. Esta propuesta añade el disparador, el almacenamiento y su ciclo de vida.

## 2. El problema, con números reales

La foto es **obligatoria por invitado** y admite hasta **12 MB**. Una solicitud de 5 invitados puede pesar **60 MB**.

Eso descarta de entrada dos opciones que parecen naturales:

**❌ Incluirla en el lote de `pendientes` (base64).** Inflaría el JSON ~33 %, y ese JSON **es exactamente el string que se firma con HMAC**: estaríamos calculando SHA-256 sobre decenas de megabytes cada 5 minutos. Peor aún, acopla dos fallos que deben ir separados: si una imagen falla, se cae la importación de la solicitud, que es lo importante.

**❌ Que el Portal la empuje por POST.** Imposible por diseño: la regla R1 prohíbe que el servidor público inicie conexiones hacia Tlalpan. Todo en este canal es Tlalpan saliendo a buscar.

**❌ Descargarla dentro del ciclo de importación.** El ciclo hoy tarda 0.65 s y es predecible. Con descargas dentro, un lote de 3 solicitudes tardaría entre 1 s y 90 s según cuántas fotos traiga — lo que rompe el supuesto de `withoutOverlapping()` con cadencia de 5 minutos.

**El costo no es el tamaño total: es pagarlo en el lugar equivocado.**

## 3. Diseño propuesto

```
cron A (invitados:importar-portal)
   │  importa la solicitud  ─────────────────────────────► inv_solicitud
   │  guarda la referencia  ─────────────────────────────► solicitudInvitadoDocumento
   │  despacha 1 job por documento (INSERT en `jobs`)
   │  vigila el rezago de la cola ──────────────────────► incidencia A6
   ▼
worker (queue:work, ya existe bajo Supervisor)
   │  DescargarDocumentoInvitado
   │    ├─ circuit breaker: si el Portal está caído, no quema timeouts
   │    ├─ GET firmado /invitados/transferencia/documento/{id}
   │    ├─ valida tamaño y MIME por contenido
   │    ├─ guarda en disco PRIVADO
   │    └─ escribe solicitudInvitadoDocumentoLocal
   ▼
vista del operador
   └─ ¿el archivo está en disco? → lo sirve
      ¿no está?                 → lo descarga al vuelo (red de seguridad)
```

### 3.1 Por qué job y no un cron dedicado

Se evaluaron ambos. **Gana el job**, y la razón es que la alternativa es reimplementar a mano lo que el framework ya trae:

| | Cola | Cron con presupuesto |
|---|---|---|
| Reintentos con espera creciente | `$tries` + `$backoff` | A mano |
| Fallos definitivos auditables | `failed_jobs` + `queue:retry` | A mano |
| Aislamiento por archivo | 1 job = 1 archivo | A mano |
| Concurrencia | Número de workers | A mano |
| Latencia | Segundos | Hasta 10 min |

Y la infraestructura **ya está operando**: existen `app/Jobs/ProcesarNotificacionWeb` y `ProcesarCorreosConfirmacion`, las tablas `jobs`/`failed_jobs`, y un worker bajo Supervisor.

> **El argumento del `GRANT` era menor de lo que parecía.** `DatabaseQueue::pushToDatabase()` es un solo `INSERT`. Quien necesita `SELECT`/`UPDATE`/`DELETE` sobre `jobs` es el *worker*, que corre con el usuario de la aplicación, no con `sicam_invitados`. El usuario restringido solo suma **`INSERT`** (y `SELECT` para vigilar el rezago): **se conserva la propiedad de "sin `DELETE` en ninguna tabla"**.

### 3.2 Circuit breaker — no un *ping* previo

Con el Portal caído y 200 jobs en cola, cada uno quemaría su timeout de 60 s: horas de worker ocupado en nada, con el resto de la cola detrás.

La solución **no** es preguntar "¿hay conexión?" antes de cada descarga: eso duplica peticiones sin aprender nada (la descarga misma responde esa pregunta) y es una carrera —hay conexión en T y no en T+1—.

La solución es un **contador de fallos**: si N descargas consecutivas fallan, el canal se declara abierto y los jobs siguientes se reprograman sin intentar, durante M minutos. Se implementa con el `Cache` que ya se usa para el throttle de correos. **Sin endpoint nuevo y sin cambios en el Portal.**

Ahí sí cabe una sonda ligera, pero en otro papel: cuando el breaker toca reintentar (*half-open*), es más barato probar con un `HEAD` que arrancar una descarga de 12 MB para tantear. Es una optimización, no la puerta de entrada.

### 3.3 Lo que NO se replica del modelo de mensajería móvil

- **Descargas reanudables por `Range`.** Existen para archivos grandes sobre redes móviles inestables. Aquí son imágenes de ≤12 MB en red privada: sobreingeniería.
- **Política de red (wifi vs datos).** Tlalpan tiene una sola red y una sola política.

Lo que sí se toma prestado —y ya está hecho— es lo esencial: **separar el metadato del medio**. La solicitud llega completa y al instante con un puntero; el archivo viaja aparte con su propia máquina de estados.

## 4. Cambios de esquema

### Tlalpan — `siasturianov6`

```sql
ALTER TABLE `inv_solicitudinvitado`
  ADD COLUMN `solicitudInvitadoDocumentoLocal` varchar(255) NULL DEFAULT NULL
      COMMENT 'Ruta del documento ya descargado en el disco privado de Tlalpan. NULL = pendiente.'
      AFTER `solicitudInvitadoDocumento`,
  ADD COLUMN `solicitudInvitadoDocumentoIntentos` tinyint NOT NULL DEFAULT 0
      COMMENT 'Intentos de descarga fallidos. Corta el reintento infinito.'
      AFTER `solicitudInvitadoDocumentoLocal`,
  ADD INDEX `idx_si_documento_pendiente` (`solicitudInvitadoDocumentoLocal`);
```

**Por qué una columna y no preguntarle al sistema de archivos:** `IS NULL` deja la consulta de pendientes trivial e indexable, y sobre todo **distingue "todavía no" de "falló para siempre"**. Un `file_exists()` por fila no puede.

**Por qué conservar `solicitudInvitadoDocumento`:** es la referencia remota, y sin ella no hay forma de reintentar ni de re-descargar.

**No se elimina ni se altera ninguna columna existente.**

### Portal — `asturias_api`

**Ninguno.** El Portal ya tiene todo lo que necesita. Sus cambios son de código (§5.2).

## 5. Cambios de código

### 5.1 Tlalpan (`CL001SICAMV2`)

| Artefacto | Acción |
|---|---|
| `app/Jobs/DescargarDocumentoInvitado.php` | **Nuevo.** Un job por documento, con `$tries`/`$backoff`, breaker y validación |
| `app/Library/FSG/InvitadosPortal/CircuitBreakerPortal.php` | **Nuevo.** Contador de fallos sobre `Cache` |
| `app/Library/FSG/InvitadosPortal/AlmacenDocumentos.php` | **Nuevo.** Ruta canónica, validación y escritura en disco privado |
| `app/Http/Controllers/Invitados/DocumentoInvitadoController.php` | **Nuevo.** Sirve el archivo al operador, con descarga perezosa si falta |
| `TransferenciaSolicitudesPortal` | **Extender.** Despachar jobs tras importar; vigilar el rezago (A6) |
| `MonitorInvitados` | **Extender.** Constante `A6` (rezago de descargas) |
| `config/invitados.php` | **Extender.** Bloque `documentos` |
| `routes/web.php` | **+1 ruta** autenticada para el operador |
| Vista de la solicitud | **+1 enlace** por invitado |

**No se toca** el flujo de calificación, ni `buscarOCrearInvitado()`, ni `vincularInvitadoSolicitud()`, ni `HmacSigner`, ni los dos procesos de fan-out.

### 5.2 Portal (`CL032BACK`)

| Artefacto | Acción | ¿Obligatorio? |
|---|---|---|
| `InvitadoResource` | **+2 campos**: `documentoBytes` y `documentoSha256` | Recomendado |
| `routes/.../transferencia.php` | **+1 ruta** `HEAD /invitados/transferencia/salud` | Opcional |
| `app/Console/Commands/PurgarDocumentosInvitados.php` | **Nuevo.** Purga a los N días | **Condicionado** (§7) |

**Por qué `documentoBytes` y `documentoSha256`:** permiten a Tlalpan (a) **rechazar antes de descargar** un archivo que excede el tope, (b) **verificar integridad** de lo recibido, y (c) **detectar reemplazo** si el socio cambia la foto. Sin ellos, Tlalpan descarga a ciegas y no puede saber si lo que tiene sigue vigente. `Storage::size()` es un `stat` barato; el hash requiere leer el archivo, por eso se marca como recomendado y no como obligatorio.

## 6. Almacenamiento y seguridad en Tlalpan

Son identificaciones oficiales de terceros. Estas seis reglas no son opcionales:

1. **Disco privado.** `storage/app/invitados/documentos/{año}/{idSolicitud}/{idSolicitudInvitado}.{ext}`. **Jamás** `storage/app/public`: no puede quedar accesible por URL.
2. **Servido por controlador**, validando la sesión del operador. El proyecto ya tiene ese patrón en `ConstanciaVidaController` y `BeneficiarioFiscalController`.
3. **Tope de tamaño**, verificando `Content-Length` **y** cortando el stream. El Portal admite 12 MB y Tlalpan está alineado en el mismo valor (12 582 912 bytes). Aun coincidiendo, Tlalpan debe imponer su propio techo y no confiar en el del otro lado: la alineación es un hecho de configuración de hoy, no una garantía del canal (ver `NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md` §2).
4. **MIME por inspección del contenido** (`finfo`), nunca por la extensión ni por el header que manda el Portal. Lista blanca: `image/jpeg`, `image/png`, `image/webp`.
5. **El nombre local se deriva de la PK local**, nunca de la referencia remota. Lo contrario es un *path traversal* servido en bandeja.
6. **Ningún dato del Portal se trata como confiable**, aunque venga firmado: la firma acredita el origen, no la inocuidad del contenido.

## 7. Retención — la decisión que condiciona todo

> ⚠️ **Pendiente de autorización: ¿el Portal purgará los documentos a los 30 días de la fecha de salida?** (decisión D6)

El diseño cubre los dos desenlaces sin rehacerse:

**Si se autoriza la purga** → la descarga a Tlalpan es **obligatoria**, porque Tlalpan pasa a ser el archivo de registro. Y la purga del Portal debe ser doblemente segura:

```sql
-- El Portal solo purga lo que Tlalpan ya acusó como transferido
WHERE solicitudTransferencia = 'Si'
  AND solicitudFechaSalida < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
```

La bandera `solicitudTransferencia` ya existe y ya se marca. Con eso, una solicitud que nunca llegó a Tlalpan **nunca se purga**, aunque envejezca.

**Si NO se autoriza** → la descarga es una **optimización**: el operador ve la foto al instante y el sistema resiste caídas del Portal. Todo lo demás del diseño aplica igual.

**En ambos casos, Tlalpan necesita su propia política de conservación**, porque pasa a almacenar datos personales sensibles. Eso es una decisión del negocio, no del código. Mientras no exista, el sistema conserva indefinidamente y crece: con 12 MB máximos por invitado, conviene dimensionarlo antes de arrancar.

## 8. Sobre Redis

El diseño **no lo asume ni lo necesita**. Funciona hoy con `QUEUE_CONNECTION=database` y `CACHE_DRIVER=file`.

Si más adelante se instala, la migración es de configuración, no de código:

| | Hoy | Con Redis |
|---|---|---|
| Cola | `database` | `redis` → **el `GRANT` sobre `jobs` deja de hacer falta** |
| Breaker y throttle | `Cache` en archivo | `Cache` en Redis → contador atómico entre procesos concurrentes |

Ninguna clase cambia: ambos usan las fachadas `Queue` y `Cache`. Conviene **no** condicionar esta entrega a que Redis exista.

## 9. Riesgos y mitigaciones

| Riesgo | Mitigación |
|---|---|
| El worker muere y los jobs se acumulan **en silencio** | El cron A vigila el rezago (`jobs` de documentos con más de 15 min) y dispara la incidencia **A6**. Es la única objeción de peso contra la cola, y se tapa con una consulta |
| El Portal cae y la cola quema timeouts | Circuit breaker (§3.2) |
| El socio reemplaza la foto tras la transferencia | `documentoSha256` en el payload permite detectar el cambio y re-descargar |
| El disco de Tlalpan crece sin control | Política de retención (§7) + alerta de espacio |
| Un archivo malicioso | Validación por contenido, tope de tamaño, nombre derivado de la PK local, fuera de `public/` (§6) |
| El job hereda la conexión restringida y la filtra | El job **no** usa el trait `UsaConexionInvitados`: el worker es de larga vida y un `config(['database.default'...])` se filtraría a los jobs siguientes. Usa `DB::connection('mysql_invitados')` explícito en su único `UPDATE` |

## 10. Plan de entrega

| Fase | Entregable | Depende de |
|---|---|---|
| **0** | Autorización de la purga (§7) y medición del volumen real | — |
| **1** | Portal: `documentoBytes` + `documentoSha256` en `InvitadoResource` | — |
| **2** | Tlalpan: SQL manual de las columnas nuevas | — |
| **3** | Tlalpan: `AlmacenDocumentos` + `CircuitBreakerPortal` + job + despacho desde el cron A | 1, 2 |
| **4** | Tlalpan: controlador, ruta y enlace en la vista del operador (con descarga perezosa) | 3 |
| **5** | Tlalpan: vigilancia del rezago (A6) + `GRANT` sobre `jobs` | 3 |
| **6** | Portal: comando de purga | **0** |
| **7** | `.md` de configuración en ambos proyectos + SQL manual | 5, 6 |

**Las fases 1–5 no dependen de la autorización de la purga.** Se pueden entregar mientras esa decisión se resuelve; solo la 6 queda bloqueada.

## 11. Criterios de aceptación

1. Importar un lote con fotos **no alarga el ciclo de importación** de forma perceptible.
2. Con el Portal apagado, la cola **no quema timeouts**: el breaker abre y los jobs se reprograman.
3. Un documento que falla 3 veces queda en `failed_jobs` y **no se reintenta para siempre**.
4. El operador abre una solicitud recién importada y **ve la foto**, aunque el job aún no haya corrido (descarga perezosa).
5. La ruta que sirve el archivo **rechaza a un usuario sin sesión**.
6. Un archivo que no es imagen, o que excede el tope, **se rechaza y se bitacoriza**.
7. Si el worker se detiene, **llega el correo A6** dentro del ciclo siguiente.
8. Nada de lo anterior requiere tocar el flujo de calificación.
