# Runbook de despliegue — mbtaller multi-tenant (fase 1: base firme)

Alcance: pasar el servidor de producción del código viejo (`main`) al código de
`feat/multi-tenant` tal como está hoy — login funcionando, contenedor
arrancando limpio. **No incluye** TenantGuard, switch de tenant, panel, ni
conversión de más módulos — eso viene después de confirmar que esto funciona.

Server: `132.226.40.48`, MySQL en el puerto `3310`. La demo/producción real
(`mbinvautocentrorodriguez`) y el plano de control (`mbinvtaller`) viven en el
**mismo servidor MySQL**, bases distintas.

---

## 0) Antes de tocar nada — un solo pendiente, y es tuyo, no mío

1. **Confirmar o correr vos mismo el login final con una cuenta real.** Verifiqué
   la mecánica completa (login por username, login por email, rechazo de
   username inexistente, cifrado/descifrado de la credencial del tenant) con
   una identidad descartable que creé y borré en el mismo momento — nunca tuve
   ni necesité ninguna contraseña real. Antes de dar esto por cerrado, alguien
   con una contraseña real (admin, mb, superadmin, user o Rudy) tiene que
   loguearse una vez contra el ambiente ya desplegado.

---

## 1) Respaldo — qué, y cómo confirmar que sirve

Dos bases, ambas en el mismo server. Corré esto desde una máquina con acceso a
`132.226.40.48:3310` (la misma que usás para migraciones):

```bash
mysqldump -h 132.226.40.48 -P 3310 -u <TENANT_CLI_DB_USERNAME> -p \
  mbinvautocentrorodriguez > backup-tenant-$(date +%Y%m%d-%H%M).sql

mysqldump -h 132.226.40.48 -P 3310 -u <CONTROL_PLANE_DB_USERNAME> -p \
  mbinvtaller > backup-control-plane-$(date +%Y%m%d-%H%M).sql
```

**Verificar que el respaldo sirve** (no solo que el archivo existe):

```bash
# El dump del tenant tiene que tener las 23 migraciones y las 7 filas de
# taller_usuarios -- si no, algo salió mal en el dump.
grep -c "INSERT INTO \`taller_usuarios\`" backup-tenant-*.sql
grep -c "INSERT INTO \`migrations\`" backup-tenant-*.sql

# El de mbinvtaller hoy tiene que estar CASI vacío (identidades=0, tenants=0
# -- confirmado antes de escribir esto). Si el dump muestra otra cosa, algo
# escribió ahí antes de que yo lo esperara -- parar y revisar antes de seguir.
grep -c "INSERT INTO \`identidades\`" backup-control-plane-*.sql
```

`mbinvtaller` está prácticamente vacío hoy — este backup es barato y es tu
punto de retorno "nada de esto pasó todavía".

---

## 2) Cambios en el `.env` del servidor — línea por línea

**El hallazgo más importante de este runbook, y no estaba en tu lista de tres
bloqueantes:** el código viejo (el que corre hoy) usa `DB_HOST`/`DB_PORT`/
`DB_DATABASE`/`DB_USERNAME`/`DB_PASSWORD` (confirmado leyendo
`app.module.ts` y `docker-compose.yml` de `main`, el commit que está
desplegado ahora). El código nuevo usa `TENANT_CLI_DB_*` — un rename
deliberado de una ronda anterior de este mismo trabajo (hay una prueba,
`app.module.env-naming.spec.ts`, que falla si algún archivo vuelve a usar los
nombres viejos — la razón está en su propio comentario: evitar un bug real de
otro proyecto donde un nombre de variable genérico terminaba cayendo a una
base por defecto). **Si no renombrás esto en el `.env` del servidor, el
contenedor nuevo no arranca** — mi preflight (sección 3) lo va a decir
clarito, pero mejor evitarlo.

Editá el `.env` del servidor (junto a `docker-compose.yml`) así:

```bash
# ── RENOMBRAR (mismos valores que ya tenías, solo cambia el nombre) ──
# Buscá tus valores actuales de DB_HOST/DB_PORT/DB_DATABASE/DB_USERNAME/
# DB_PASSWORD y ponelos en estas variables NUEVAS. Podés borrar las viejas
# una vez confirmado que el contenedor nuevo arranca (ver seccion 6) -- no
# hace falta borrarlas antes, docker-compose.yml nuevo ya no las lee.
TENANT_CLI_DB_HOST=132.226.40.48
TENANT_CLI_DB_PORT=3310
TENANT_CLI_DB_DATABASE=mbinvautocentrorodriguez
TENANT_CLI_DB_USERNAME=<tu usuario actual, el mismo de siempre>
TENANT_CLI_DB_PASSWORD=<tu password actual, el mismo de siempre>

# ── NUEVO — plano de control (mbinvtaller), mismo servidor y puerto ──
CONTROL_PLANE_DB_HOST=132.226.40.48
CONTROL_PLANE_DB_PORT=3310
CONTROL_PLANE_DB_DATABASE=mbinvtaller
CONTROL_PLANE_DB_USERNAME=<mismo usuario de arriba, o uno con acceso a mbinvtaller>
CONTROL_PLANE_DB_PASSWORD=<su password>

# ── NUEVO — llave de cifrado de credenciales de tenant ──
# Generar UNA VEZ, guardar en un lugar seguro (gestor de contraseñas de la
# empresa, no solo este .env) -- si se pierde, las credenciales de tenant ya
# cifradas en mbinvtaller son irrecuperables para siempre. Generarla así:
#   openssl rand -base64 32
CONTROL_PLANE_CREDENTIALS_ENCRYPTION_KEY=<pegar el resultado de ese comando>

# ── SIN CAMBIOS — ya existen en tu .env actual, no tocar ──
# JWT_SECRET=...
# JWT_EXPIRES_IN=...
# (el resto de WHATSAPP_*, CORS_ORIGINS, etc. tampoco cambian)
```

**Bloque completo listo para pegar**, orden de aplicación (renombrar primero,
agregar después, nunca borrar antes de confirmar que el nuevo contenedor
arrancó):

```env
TENANT_CLI_DB_HOST=132.226.40.48
TENANT_CLI_DB_PORT=3310
TENANT_CLI_DB_DATABASE=mbinvautocentrorodriguez
TENANT_CLI_DB_USERNAME=__COPIAR_DEL_DB_USERNAME_ACTUAL__
TENANT_CLI_DB_PASSWORD=__COPIAR_DEL_DB_PASSWORD_ACTUAL__

CONTROL_PLANE_DB_HOST=132.226.40.48
CONTROL_PLANE_DB_PORT=3310
CONTROL_PLANE_DB_DATABASE=mbinvtaller
CONTROL_PLANE_DB_USERNAME=__MISMO_USUARIO_O_UNO_CON_ACCESO_A_MBINVTALLER__
CONTROL_PLANE_DB_PASSWORD=__SU_PASSWORD__

CONTROL_PLANE_CREDENTIALS_ENCRYPTION_KEY=__RESULTADO_DE_openssl_rand_-base64_32__
```

---

## 3) Migraciones — cuáles, contra qué base, y cuáles ya están

**Tenant (`mbinvautocentrorodriguez`) — YA APLICADAS, NO REPETIR POR MIEDO,
pero el comando es idempotente si lo corrés igual:**

Confirmado hoy con una consulta real (`SELECT name FROM migrations` contra la
base real): las 23 migraciones de tenant están aplicadas, incluido
`DropUnusedSyncEvents1770700000000` (el DROP de `taller_sync_events`).
Verificado además que el código viejo desplegado ahora **no referencia esa
tabla en ningún lado real** (`sync.service.ts` tiene su propio `interface
SyncEvent` local, sin relación con la entidad de base de datos — confirmado
leyendo `sync.module.ts` de `main`: su `TypeOrmModule.forFeature([...])` no
incluye `SyncEvent`). El DROP no rompe nada del código que está corriendo
ahora.

Si por algún motivo querés confirmarlo de nuevo antes de desplegar:

```bash
cd backend
TENANT_CLI_DB_DATABASE=mbinvautocentrorodriguez npm run migration:show
```

**Plano de control (`mbinvtaller`) — YA APLICADAS también** (las 2 migraciones
que crean sus tablas — confirmado con la misma consulta). Comando para
confirmar:

```bash
cd backend
npm run migration:cp:run   # idempotente -- si ya estan, no hace nada y lo dice
```

---

## 4) Script de identidades — CUÁNDO y el comando exacto

**Antes** de levantar el contenedor nuevo, no después — si el contenedor nuevo
queda arriba sin identidades migradas, cualquiera que intente loguearse ve
"Credenciales inválidas" hasta que corras esto, con el mismo password de
siempre. Mejor que esa ventana no exista.

Ya no hace falta completar nada — `mb` cambió de "correo real pendiente" a
placeholder (`mb@sin-correo.invalid`), mismo formato que `admin`/`superadmin`.
Motivo del cambio, documentado también en el propio script: en este
despliegue `mb` es un usuario más del tenant, no una identidad de plataforma
(no hay panel, ni vía de plataforma, ni switch todavía) — ponerle un correo
personal ahora la ataría a algo que después hay que separar. Cuando exista el
panel y la identidad de plataforma sea real, se crea una identidad NUEVA con
correo real y `rolPlataforma=admin_plataforma`, separada de esta — anotado
como deuda, no resuelto acá.

**Comando — sin Node instalado, todo por `docker compose` (el caso real del
servidor):**

```bash
sudo docker compose --profile tools run --rm backend-tools scripts/migrate-identidades.ts --apply
```

`backend-tools` es un servicio nuevo, con `profiles: ["tools"]` — por eso
**nunca** arranca con un `docker compose up` normal (no le agrega peso ni
tiempo al arranque de todos los días), y se construye desde un stage del
`Dockerfile` (`tools`) que es el único que conserva `ts-node` y el resto de
las devDependencies — la imagen de `backend` que se despliega de verdad
sigue exactamente igual de liviana que antes. Lee las variables del mismo
`.env` que ya editaste en la sección 2 (docker-compose las inyecta igual que
al servicio `backend`) — no necesita ni busca ningún archivo `.env` dentro
del contenedor.

Si en algún momento tenés Node en la máquina desde la que operás (no en el
servidor, pero sí por ejemplo tu laptop con acceso directo a la base), la
alternativa sigue siendo válida:

```bash
cd backend
npx ts-node scripts/migrate-identidades.ts --apply
```

Cualquiera de los dos va a imprimir una línea por cada identidad/membership/
tenant creado. Esperá ver:
- 1 tenant creado (`autocentro-rodriguez`)
- `taller1`/`taller2`: `activo=0` (desactivados, no migrados)
- 5 identidades creadas: `admin`, `mb`, `superadmin`, `user`, `Rudy`

Si algo salió mal y hay que deshacerlo (nunca toca el esquema, solo las filas
que él mismo creó):

```bash
sudo docker compose --profile tools run --rm backend-tools scripts/migrate-identidades.ts --revert
```

**Este comando NO es opcional en un rollback** — ver la sección 7, "Cómo
volver atrás", el punto sobre `taller1`/`taller2`.

**Esto sirve para cualquier script futuro** que se agregue a
`backend/scripts/` — el `ENTRYPOINT` de `backend-tools` es genérico
(`npx ts-node`), no específico de este script. Uso general:

```bash
sudo docker compose --profile tools run --rm backend-tools scripts/<el-que-sea>.ts [args]
```

---

## 5) Comando de despliegue

Desde el servidor, en el directorio del proyecto, con `feat/multi-tenant` ya
mergeada o checkouteada:

```bash
git checkout main
git merge feat/multi-tenant     # o el flujo de PR que uses
docker compose down
docker compose up -d --build
```

`NEXT_PUBLIC_API_URL` en `docker-compose.yml` ya es una ruta relativa
(`/api/v1`, no una URL absoluta con host) — confirmado leyendo el compose file
actual. El frontend **no necesita rebuild por cliente ni cambio de valor**: no
toqué el frontend (ver sección "Frontend" más abajo), y aunque lo hubiera
tocado, esa variable ya está diseñada para no depender del dominio.

---

## 6) Cómo verificar que funcionó — en orden

**a) El contenedor levantó:**

```bash
docker compose ps
```

`tll-backend-prod` tiene que decir `healthy`. Si dice `restarting` o
`unhealthy`, mirá los logs:

```bash
docker compose logs --tail=80 backend
```

- Si ves `ARRANQUE ABORTADO — falta configuracion`: el contenedor salió con
  código 1 ANTES de escuchar el puerto — el preflight te dice exactamente qué
  variable falta o qué migración no está aplicada. Arreglá eso puntual (no es
  un fallo de la app, es el `.env`) y volvé a `docker compose up -d --build`.
  No hace falta rollback por esto.
- Si ves `AVISO DE ARRANQUE` (un bloque parecido pero que NO aborta — el
  contenedor sigue arrancando igual): el preflight encontró el plano de
  control migrado pero **vacío** (esto es exactamente lo que pasó el 12/08 —
  el contenedor quedó `healthy` y nadie pudo entrar, sin ninguna pista en los
  logs, porque este chequeo todavía no existía). El aviso te da el comando
  exacto para arreglarlo — es el mismo de la sección 4 (`--apply`). Corré ese
  comando, no hace falta reiniciar el contenedor para que el login empiece a
  funcionar.
- **Confirmación positiva, para no depender de "silencio = todo bien":** con
  todo en orden vas a ver una línea así en los logs, no solo ausencia de
  errores:
  ```
  [preflight] Plano de control OK: N tenant(s), M identidad(es). El login deberia funcionar.
  ```
  Si no ves esta línea NI el aviso NI el abort, algo se salió de lo esperado
  — avisame antes de asumir que está bien.
- Si el contenedor arranca pero se cae después (o los logs muestran un error
  que NO es del preflight): ir a la sección 7, rollback.

**b) Login real — el paso que de verdad dice si funciona.** Con una cuenta
real (`user` o `Rudy`, que ya tienen contraseña conocida por su dueño — evitá
`admin`/`mb`/`superadmin` para esta prueba salvo que estés 100% seguro de la
contraseña):

```bash
curl -s -X POST http://132.226.40.48:3001/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"Rudy","password":"<su password real>"}'
```

- **Esperado:** JSON con `accessToken`, `refreshToken`, `user`. Esto es LO
  QUE IMPORTA — si esto funciona, la identidad está migrada, el tenant está
  registrado, la credencial cifrada descifra bien, y el login por username
  (el puente para no tocar el frontend) funciona.
- **Si da `{"message":"Credenciales invalidas", ...}` con la contraseña
  correcta:** el script de identidades no corrió, o corrió contra el `.env`
  equivocado (revisá que apuntó a `mbinvautocentrorodriguez`/`mbinvtaller`
  reales, no a algún resto de una prueba). No es un fallo del contenedor —
  no hace falta rollback, hace falta correr `scripts/migrate-identidades.ts
  --apply` (sección 4) y repetir este paso.
- **Si da un 500** (no "Credenciales invalidas", un error de servidor): esto
  sí es un fallo real de la app — ir a la sección 7, rollback.

**c) Dos o tres pantallas del sistema, con el usuario ya logueado en el
navegador** (no solo el curl del paso b — confirmar que la UI también entra):

1. Dashboard / lista de clientes (`GET /api/v1/clientes`) — tiene que mostrar
   clientes reales, no una lista vacía ni un error.
2. Lista de vehículos o de OTs abiertas — mismo criterio: datos reales.
3. Abrir una OT existente y ver sus datos (daños, fotos, etc.) — confirma que
   las rutas con parámetros (`:id`) también responden.

Si el login del paso b funcionó pero alguna de estas pantallas da error:
anotá CUÁL pantalla y qué mensaje/status ves, y mandámelo — no es
necesariamente motivo de rollback (puede ser una ruta puntual), pero necesito
verlo antes de decidir.

**d) Qué significa cada fallo, y a qué hacer:**

| Síntoma | Qué significa | Qué hacer |
|---|---|---|
| Contenedor no llega a `healthy`, logs dicen `ARRANQUE ABORTADO` | Falta una variable de entorno o una migración | Arreglar el `.env`/correr la migración que falte, reintentar el `up -d --build`. NO es rollback. |
| Contenedor `healthy`, pero login da "Credenciales invalidas" con password correcta | Falta correr el script de identidades, o corrió contra la base equivocada | Correr `--apply` (sección 4), repetir el login. NO es rollback. |
| Login da 500 | Fallo real de la app | **Rollback (sección 7).** |
| Login funciona, pero una pantalla del sistema da error | Puede ser puntual de esa ruta | Avisame con el detalle antes de decidir — no asumas rollback todavía. |
| Varias pantallas fallan, o el sitio entero no responde | Fallo real y extendido | **Rollback (sección 7).** |

---

## 7) Cómo volver atrás si algo falla

**a) `--revert` es un PASO OBLIGATORIO del rollback, no opcional.**
Verificado por vos y confirmado por mí leyendo el código real de `main`
(`auth.service.ts:167`, el login viejo): `where: { username, activo: true }`.
El script de identidades deja `taller1`/`taller2` en `activo=0` — si volvés
al código viejo SIN correr `--revert`, esas dos cuentas siguen sin poder
loguearse, con el mismo password de siempre, exactamente como pasa hoy con el
código nuevo. El rollback de código NO deshace esto por sí solo.

**b) Orden exacto: primero `--revert`, después el código.**

```bash
sudo docker compose --profile tools run --rm backend-tools scripts/migrate-identidades.ts --revert

docker compose down
git checkout main
docker compose up -d --build
```

Justificación del orden: `--revert` es una operación de base de datos pura
(conexión directa por `mysql2` — `backend-tools` es solo el empaque para
correrla sin tener Node en el servidor, no cambia la naturaleza de la
operación) — no depende de que el backend esté arriba, abajo, viejo o nuevo,
así que correrla primero no tiene ningún costo ni riesgo adicional. Hacerlo
en este orden significa
que en el momento exacto en que el código viejo empieza a responder,
`taller1`/`taller2` YA están reactivados — cero ventana adicional de esas dos
cuentas caídas mientras dure el resto del rollback. Si lo invertís (código
primero, `--revert` después), esas dos cuentas quedan sin poder entrar
durante todo el tiempo que tardes en acordarte de correr el script — evitable
corriéndolo primero.

**c) Qué queda en `mbinvtaller` después del `--revert`, y si molesta al
código viejo.** Verificado (no supuesto): después de `--revert`,
`mbinvtaller.identidades`, `.tenants` y `.tenant_membership` vuelven a 0 filas
— exactamente el estado de antes de correr `--apply` (lo comprobé yo mismo
en esta sesión, con un ciclo real de `--apply` + `--revert` contra las bases
reales). Y **no importaría aunque quedaran filas**: confirmado leyendo
`app.module.ts` de `main` — el código viejo no importa `ControlPlaneModule`
ni referencia ninguna variable `CONTROL_PLANE_DB_*` en absoluto. No sabe que
`mbinvtaller` existe. Cualquier fila que quedara ahí sería invisible para él,
nunca un problema.

**d) El caso feo — revertiste el código pero te olvidaste del `--revert`:**
el síntoma es específico y reconocible, no un fallo genérico. El login
general del sitio funciona bien (todos los demás usuarios entran normal) —
**solo `taller1` y `taller2`, puntualmente, no pueden entrar con su
contraseña de siempre**, y el mensaje que ven es igual de genérico que si
hubieran escrito mal el password ("Credenciales inválidas" — el código viejo
no distingue "desactivado" de "contraseña incorrecta", confirmado en el mismo
`where: { activo: true }`). Por eso el síntoma desde afuera es ambiguo — la
forma de confirmarlo es con una consulta directa, no esperando a que alguien
se queje dos veces:

```sql
SELECT username, activo FROM taller_usuarios WHERE username IN ('taller1', 'taller2');
```

Si devuelve `activo = 0` para cualquiera de los dos DESPUÉS de un rollback
completo, es exactamente este caso — correr `--revert` soluciona.
`taller_usuarios.activo` es la única fuente de verdad acá, nunca el mensaje
de error del login (que es igual en los dos casos a propósito, por diseño
del login viejo, no un bug nuevo).

**Sobre el código en sí:** revertir es seguro incluso después de las
migraciones ya aplicadas — confirmado en la sección 3, el código viejo no
toca nada que las migraciones hayan tocado de forma incompatible. No borres
`DB_HOST`/`DB_PORT`/etc. (los nombres viejos) del `.env` hasta confirmar el
despliegue nuevo (sección 6) — si hay que volver atrás, el código viejo los
sigue necesitando con sus nombres originales.

Si por algún motivo el rollback de código + `--revert` no alcanza (algo
escribió datos incompatibles en el tenant — no debería pasar según lo
verificado, pero por si acaso): restaurar desde el backup de la sección 1.
`mysql -h 132.226.40.48 -P 3310 -u <user> -p mbinvautocentrorodriguez < backup-tenant-FECHA.sql`.

---

## Diagnóstico pedido: distribución de status de las 87 rutas (no una corrección)

Instrumenté el control positivo (VARIANTE 4) para reportar el status de CADA
una de las 87 rutas, no solo si ejecutó consulta, y lo comparé contra las
variantes negativas. Resultado real:

- **Control positivo (tenant válido + membresía activa): `{"200":22,"400":27,
  "404":37,"500":1}`.** El único 500 es `POST /api/v1/catalogos/seed`, y es
  el mismo de siempre (el cinturón de sesión de solo lectura del propio
  arnés de pruebas bloqueando un INSERT real — no existe en producción, sin
  ese cinturón). **Cero 500 nuevos causados por la conversión de auth.**
  `AuthController` no quedó roto.
- **Tenant inexistente / suspendido: 6 rutas en 500**, siempre las mismas:
  las 5 rutas propias de auth (`GET /me`, `GET /users`, `GET /tiendas`,
  `PUT /tiendas/:id`, `PUT /users/:id`) más `catalogos/seed`. Causa
  confirmada leyendo el código: `TenantConnectionRegistry` tira
  `TenantNotResolvableError` (una clase de error propia, no una excepción de
  Nest) cuando el tenant no existe o no está activo — Nest no tiene ningún
  mapeo para esa clase, así que cae al 500 genérico por defecto en vez de un
  401/403 con mensaje. **Es fail-closed funcionando de verdad** (nada se
  filtra, la request se rechaza) — el problema es solo cosmético (el código
  de status y el mensaje). Anotado como deuda, sin tocar ahora, según lo
  pedido.

---

## Frontend — qué se investigó y qué se decidió

**Lo que manda hoy:** `{ username, password }` a `POST /auth/login`
(confirmado leyendo `providers.tsx`). El `LoginDto` original (antes de esta
sesión) esperaba `{ email, password }` con `@IsEmail` — **incompatible: el
`ValidationPipe` (`forbidNonWhitelisted: true`) rechazaría cualquier request
del frontend actual con un 400**, nunca llegaba a intentar el login.

**Decisión aplicada (tu preferencia, con el costo que pediste que te avisara
antes de descartarla):** `LoginDto` ahora tiene un campo `username` (sin
`@IsEmail`) que acepta email real O username local — el frontend no cambia,
cero rebuild, cero variable nueva. `AuthService.resolveIdentidadPorLogin()`
intenta primero como email (búsqueda directa en `identidades`); si no
encuentra nada, lo trata como username local y busca en cada tenant ACTIVO
(hoy: uno solo) un usuario local con ese username — si aparece en
exactamente uno, resuelve la identidad vía `tenant_membership`, sin adivinar
ningún email.

**El costo, para que lo tengas escrito:** esto NO escala más allá de un
puñado de tenants. Cada intento de login sin coincidencia de email recorre
TODOS los tenants activos preguntando "¿tenés este username?". Con dos
tenants que casualmente tengan el mismo username activo (ej. "admin" en
ambos), el login rechaza pidiendo email en vez de adivinar cuál — correcto,
pero significa que este puente deja de ser gratis apenas haya varios tenants.
Quedó documentado en el código (`auth.service.ts`, comentario sobre
`resolveIdentidadPorLogin`) como algo a reemplazar — por login-por-email en
el frontend, o un índice de username global — antes de onboardear un tenant
nuevo cuyos usernames puedan repetirse con el actual.

Verificado de punta a punta (identidad descartable, creada y borrada en el
mismo momento, nunca con una contraseña real): login por username ✅, login
por el email real de la misma identidad ✅, rechazo limpio de un username que
no existe en ningún tenant ✅.

`NEXT_PUBLIC_API_URL` es build-time en Next.js, confirmado — pero como el
frontend no cambia en absoluto, esto no aplica a este despliegue. Si en el
futuro el frontend SÍ cambia (ej. para pasar a login-por-email), ahí sí
importa: hoy el valor default en `docker-compose.yml` ya es relativo
(`/api/v1`), así que ni siquiera ese cambio futuro obligaría a una imagen por
cliente.

---

## Cambios de comportamiento visibles — para avisar al equipo ANTES

Los cinco que ya tenías identificados (sin volver a auditarlos yo — son de
rondas anteriores, no tocados en esta):

1. `es_servicio` pasando de servicio a producto en 5.539 productos.
2. `soloTaller` ahora filtra de verdad — antes no hacía nada, ahora puede
   devolver cero resultados donde antes devolvía todo.
3. Ediciones de clientes ya no propagan a `maecli` (el ERP).
4. Config de impresión de tiendas (nuevo, antes no existía).
5. Update parcial de `maecli` (antes probablemente sobreescribía la fila
   completa).

**Nuevos, de esta sesión (auth/login/jwt.strategy):**

6. **`taller1` y `taller2` dejan de poder iniciar sesión.** Decisión explícita
   (comparten el mismo correo, no se pueden migrar a dos identidades
   distintas con el mismo email) — se desactivan en `taller_usuarios`. Si
   alguien los usa activamente, se van a encontrar con "Credenciales
   inválidas" sin haber cambiado su contraseña. Avisar ANTES si corresponde.
7. **`admin` y `superadmin` no tienen un correo real** — quedan con un
   placeholder interno (`admin@sin-correo.invalid`, etc.) que nunca deberían
   necesitar escribir: siguen entrando con su username de siempre. Si en el
   futuro alguien intenta "recuperar contraseña por correo" con esas cuentas,
   no va a haber a dónde mandar nada — no es un problema para hoy, pero hay
   que saberlo.
8. **Un usuario desactivado (`activo=false`) sigue pudiendo usar su token
   actual hasta que expire o intente un refresh** — antes, cada request
   revalidaba contra la base; ahora solo se revalida en login/refresh (TTL
   corto en vez de chequeo por request, decisión explícita de esta sesión por
   costo). Si alguien necesita cortar el acceso de una persona
   INMEDIATAMENTE, hoy eso significa además invalidar el JWT_SECRET (lo que
   desloguea a TODOS) o esperar a que expire — no hay un "cerrar sesión de
   este usuario en particular" todavía.
9. **El primer login después del despliegue tiene que ser con un tenant y una
   identidad ya migrados** — si alguien prueba a loguearse ANTES de correr el
   script de identidades (sección 4 del runbook), va a ver "Credenciales
   inválidas" con su contraseña de siempre, sin ningún otro indicio de qué
   pasó. Correr el script antes de anunciar el despliegue evita esto.
10. **Ya no hay auto-creación de `superadmin`/`admin` con contraseña fija al
   arrancar el contenedor** (`seedUsersIfTableExists()` fue eliminado — era
   además la deuda de seguridad de la credencial `superadmin`/`super123`
   fija en código). Sin impacto en este despliegue (las cuentas ya existen y
   se migran), pero relevante para cuando se diseñe el alta de un tenant
   nuevo: ese tenant NO va a tener ningún usuario local hasta que el panel le
   cree el primero — anotado como deuda de aprovisionamiento, no resuelto
   ahora.
