# Mensaje para Juan Pablo y Sergio — despliegue del Studio y autorizaciones

Hola JP, Sergio,

Les pido tres cosas distintas, y van separadas a propósito porque no se bloquean
entre sí: **desplegar** dos arreglos que ya están listos, **revisar** uno de ellos
antes de que entre, y **tomar** dos trabajos que son de su lado y no del mío.

`main` de `DCEO-CO/h2a-studio` está en **`3c92734`**. Producción no lo tiene.

---

## 1. Favicon del Studio — listo en `main`, solo falta el deploy

El Studio no tenía favicon: `base.html` no traía ningún `<link rel="icon">`, así
que cada pestaña mostraba el icono genérico de documento.

```
backend/static/img/favicon.png       nuevo   8.322 bytes
backend/static/img/favicon-dark.png  nuevo   7.955 bytes
backend/templates/base.html          +9 líneas en el <head>
```

Eso es todo: sin dependencias, sin migraciones, sin modelos, sin rutas. Los PNG
salen del material de marca 2026 (`LOGOS/PNG/Negro_1@4x.png` y `Blanco_1@4x.png`),
byte por byte como están en Drive — verifiqué el MD5 de cada uno.

**Por qué son dos.** El isotipo es una silueta de un solo color, así que con uno
solo la mitad de los casos queda invisible. Medido a 16 px sobre los grises de
Chrome: negro 21,0:1 en pestaña clara pero **1,74:1** en oscura; blanco 12,1:1 en
oscura. El mínimo para un icono es 3:1.

El link del tema claro va **primero a propósito**: los navegadores que ignoran
`media` en `rel=icon` se quedan con el primero, y la barra clara es el caso por
defecto — así el peor caso posible es el de hoy, no uno nuevo. Está comentado en
el código para que no se "ordene" después.

**Qué no verifiqué:** no pude arrancar el Studio (mi máquina no tiene `.venv`,
`.env`, Docker ni Python). Sí serví los dos archivos en la misma ruta con los
links tal como quedaron en `base.html`: responden 200 y el navegador pide el
correcto en cada tema. La aplicación completa no la corrí.

**JP:** `jp/informes-tipo-artefacto` está basada en el `main` anterior y el único
diff en `base.html` entre tu rama y `main` son estas 9 líneas. El merge es limpio,
pero que no se vayan en la resolución.

---

## 2. El login no avisaba cuando la clave estaba mal — Sergio, esto es tuyo

**El síntoma:** con correo o clave equivocados, le dabas a «Iniciar sesión» y
**no pasaba absolutamente nada**. Ni mensaje, ni error, ni señal. La pantalla
quedaba congelada y no había forma de saber si el sistema te había oído.

**No era que el servidor no respondiera.** `POST /login` renderizaba el error
perfectamente y lo enviaba —602 bytes con «Credenciales inválidas» adentro— pero
con status **401**. Y htmx 2.0.4, el que carga `base.html`, trae de fábrica:

```
responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}]
```

Cualquier 4xx cae en `swap:false`: htmx recibe la respuesta y **la descarta sin
pintarla**. El mensaje existía y nunca llegaba a la pantalla.

**Debajo había un segundo defecto que el 401 mantenía tapado.** El camino de error
devolvía `login.html`, o sea la página completa, mientras el form hace
`hx-swap="outerHTML"` sobre sí mismo. Lo reproduje con htmx 2.0.4 y las mismas
respuestas:

| | respuesta | htmx | qué ve el usuario |
|---|---|---|---|
| Como está hoy | 401 + página | `shouldSwap=false` | **nada** |
| Cambiando solo el status | 200 + página | swap | el error **y la página entera dentro de la tarjeta** |
| El arreglo | 200 + parcial | swap | solo el error, limpio |

El caso del medio importa porque es el arreglo obvio de una línea y está mal:
queda el login anidado dentro del login, con el panel izquierdo duplicado y el
`<title>` del documento sobreescrito.

**El arreglo, en rama `andres/login-error-htmx` (`d3558b8`):** el bloque error +
form sale a `partials/_login_form.html` (que no extiende `base.html`), el swap
apunta a `#login-form`, y con `HX-Request` se devuelve ese parcial con 200. Sin
htmx —JS apagado, curl— se sigue devolviendo la página entera con 401, que es lo
semánticamente correcto. Dejé comentado en `pages.py` por qué el 200 no es un
descuido: si alguien lo "corrige" de vuelta a 401, el login vuelve a enmudecer.

Verificado con el parcial real del repo: 200, `shouldSwap=true`, un solo bloque
`#login-form`, un solo mensaje encima del formulario, cero páginas anidadas, la
clave limpia y el correo conservado.

**Sergio: no lo mergeé a `main` a propósito.** `pages.py` y `login.html` son de tu
lado y es tu login. Revísalo y móntalo tú, o dime y lo mergeo.

---

## 3. Cierre de informes — un informe finalizado hoy se puede pisar en silencio

Esto es lo que más urge de las dos cosas que les pido, y no lo puedo hacer yo
porque el candado va en el servicio de publicación.

**La regla, que ya está definida:** todos los informes de H2A —redes, gestión,
resultados— tienen dos estados, como un cierre contable. **En proceso**, que se
reescribe sobre la misma versión; y **finalizado**, que lo declara una persona,
congela archivo, fecha y responsable, y **no se modifica**. Para corregir uno
finalizado hace falta autorización expresa, y al desbloquear se publica una
versión **posterior** — nunca se pisa la anterior.

**El problema:** hoy nada de eso existe en el código. `InformeRedes.estado`
(`models.py:3271`) solo conoce `borrador | publicado | retirado`, y ninguno de
esos tres significa "cerrado". Cualquier agente con una llave de escritura puede
republicar sobre el mismo slug y el cliente ve otra cosa en el mismo enlace, sin
que quede constancia de que alguien pisó un corte cerrado.

Poner el estado dentro del HTML del informe **no sirve**: el archivo se sirve
estático y cualquiera sube otro encima con el mismo slug. El candado tiene que
estar del lado de Studio.

### Los cinco puntos

| | Qué | Dónde | Qué hay ya |
|---|---|---|---|
| 1 | **Estado por informe**, con fecha de cierre y responsable | `models.py` | La columna `estado` existe; faltan el valor `finalizado` y las columnas `finalizado_at` y `finalizado_por_user_id` |
| 2 | **Botón «Finalizar informe»** en la ficha | `informes.py:371` + `templates/informes/ficha.html` | `POST /app/social/{cliente}/{informe}/estado` **ya existe** y ya recibe `estado` por form: es un tercer valor en el mismo sitio |
| 3 | **Que no se pueda republicar sobre un finalizado** | `services/informes_redes.py::publicar` | Nada. Es el punto que falta de verdad |
| 4 | **Botón «Desbloquear para editar»**, que registre quién autorizó y abra versión nueva | mismo endpoint del 2 | `restaurar` (`informes.py:388`) ya publica como versión nueva sin retroceder el contador — la mecánica está, falta el registro de quién autorizó |
| 5 | **Que el historial muestre cuáles versiones están congeladas** | `templates/informes/ficha.html` | La ficha ya lista todas las versiones; falta marcarlas |

**Sin el 3, los otros cuatro son decorativos.** Un botón que marca un estado que
nadie hace cumplir es peor que no tenerlo, porque da una confianza que no existe.

### Una corrección al plan, y es la parte importante

Yo tenía escrito que el candado iba en `preparar_informe`. **Lo revisé en el
código y ahí está mal.** `preparar_informe` (`mcp_clientes.py:1132`) no publica
nada: entrega un permiso de subida que sirve una vez y dura 24 horas. Cerrar solo
esa puerta deja las otras abiertas, y encima deja una ventana de un día entre el
permiso y la subida en la que el informe puede pasar a finalizado.

Todas las publicaciones pasan por **una sola función**, `publicar()` en
`services/informes_redes.py:161`, y tiene cinco llamadores:

```
informes.py:320          subir un informe desde la app (persona)
informes.py:403          restaurar una versión anterior
informes_publico.py:332  la subida por /u/{token} (el permiso de preparar_informe)
mcp_clientes.py:1028     publicar_informe (la herramienta MCP)
mcp_clientes.py:1086     publicar_propuesta
```

**El candado va en `publicar()`**, que es donde ya se resuelve el slug contra
`UniqueConstraint("client_id","slug")` y donde ya se decide si nace un informe o
se le agrega una versión. Ahí cierra las cinco de una vez.

Dos detalles que ahorran un rato:

- `publicar_propuesta` usa la misma función. El chequeo tiene que mirar el estado
  del informe concreto, no bloquear por tipo, o se caen las propuestas.
- Además del candado, vale un aviso temprano en `preparar_informe`: que se niegue
  a entregar el permiso si el informe ya está finalizado. No es la protección
  —esa es la de `publicar()`— pero evita que el agente arme el informe completo
  y se estrelle al final.

La especificación completa está en el cerebro, en el documento
`proceso-de-cierre-de-informes` (Natura · Consultoría de Belleza).

**Lo que les pido:** que lo tomen ustedes. Toca `models.py` —hotspot de merge—,
el servicio de publicación y la ficha de clientes, que es módulo de JP. Si
prefieren que lo escriba yo y ustedes revisan, díganme y arranco por el punto 3.

---

## 4. Clientes → proyectos

**Hoy todo cuelga del cliente en paralelo y al mismo nivel**: el cerebro, los
NITs, las métricas, los informes, las llaves MCP y las solicitudes. **No existe
ninguna entidad de proyecto** — lo confirmé en `models.py`, donde "proyecto" solo
aparece como `Contract.kind='project'`, como texto libre en
`EquipmentCheckout.project_name`, y en el docstring de `Course`.

Lo que queremos es lo obvio: que todo se guarde **bajo un cliente**, y que del
cliente salgan **proyectos** que lo alimentan — cada uno con lo suyo, sin que el
material de un proyecto se derrame al de otro ni al general del cliente.

**La buena noticia: el mecanismo ya existe y está en producción, solo que para
cursos.** `KnowledgeCollection` tiene cinco ámbitos y uno es literalmente un eje
de proyecto, según su propio docstring:

> `course_id=X` → el cerebro de ESE curso (insumos, guion, decisiones)
> *"TX-123 añade dos ámbitos más, para que el conocimiento de un ÁREA y de un
> PROYECTO no se derrame al cerebro general."*

Y `_ambito()` en `services/knowledge_base.py` es el único punto que decide qué ve
cada cerebro. O sea: el aislamiento por proyecto ya está resuelto y probado;
falta el eje equivalente para proyectos de cliente.

Así que esto es **replicar algo que ya funciona**, no arquitectura nueva. Lo caro
no es el código: es migrar lo que ya está guardado sin ámbito de proyecto, y
decidir qué pasa con el contrato de las herramientas del MCP de clientes.

**Lo que les pido:** que decidan cómo entra. Es su módulo y su modelo, así que
puede ser que lo construyan ustedes, o que lo escriba yo con JP revisando el
diseño antes de tocar `models.py`. Lo que no quiero es empezarlo por mi cuenta y
que choque con lo que ustedes ya tengan pensado para clientes.

---

## 5. Los pasos del despliegue

Primero en seco, que el script lo soporta:

```bash
cd /root/fastcode/h2a-studio && ./deploy.sh --dry-run
```

Y si se ve bien:

```bash
cd /root/fastcode/h2a-studio && ./deploy.sh
```

### Comprobación después de publicar

Los dos tienen que dar `200` — hoy dan 404:

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://studio.h2agroup.com.co/static/img/favicon.png https://studio.h2agroup.com.co/static/img/favicon-dark.png
```

Y para el login: entrar a `/login` con una clave equivocada. Tiene que aparecer
«Credenciales inválidas» encima del formulario, una sola vez y sin duplicar la
página. (Esto solo aplica si el arreglo del punto 2 ya entró a `main`.)

Si los favicons dan 200 pero la pestaña sigue con el icono genérico, es caché del
navegador: recargar en duro o abrir en incógnito.

---

## 6. El acceso, para que no dependa de ustedes la próxima vez

No alcanzo el VPS: no hay ninguna llave en `~/.ssh` —solo el `known_hosts`— y el
intento devuelve `Permission denied (publickey,password)`. Tampoco tengo `gh`
instalado. El merge a `main` sí lo hice, fast-forward y sin reescribir historia,
así que lo único que falta es correr el script en el servidor.

Es el mismo pedido que les hice para el sitio. Con acceso acotado a
`/root/fastcode/h2a-studio` y `sudo` solo para el script, despliego yo y ustedes
se enteran por el registro.

---

## 7. Lo que queda fuera de este despliegue

Para que no lo busquen:

- **Las páginas que el Studio sirve fuera de la app siguen sin favicon**: informes
  (`/r/…`), propuestas, reportes de cursos y share de redes. Llevan el color del
  cliente, no el de H2A, así que es decisión de marca y la dejé sin tomar.
- **A 16 px el isotipo es 243 de 256 píxeles opacos**: la muesca desaparece y se
  lee como un cuadrado. El contraste quedó resuelto; que se reconozca como la
  marca de H2A a ese tamaño, no. Eso pide un asset pensado para 16 px.
- **No toqué nada del módulo de Redes Sociales.**

Gracias,
Andrés
