Changelog / Releases
Historial de versiones publicadas. Las entradas se siembran al arranque y se exponen también en GET /api/v1/changelog.
Contrato ERP: la baja de un usuario solo necesita el externalId
06/10/2026 Sin cambios de comportamiento. El contrato del ERP marcaba email y fullName como obligatorios en la ingesta de usuarios sin ninguna excepcion, aunque una baja (tombstone) ya se aceptaba con el externalId solo. Ahora el contrato lo dice y un test lo fija, para que un integrador no rellene las bajas con valores inventados.
El proyecto queda registrado en Nexus desde main, para la mensajeria con los integradores.
Test de integracion que manda la baja de un usuario con solo externalId (sin email ni fullName) y verifica que se acepta y desactiva al usuario.
El contrato erp-openapi.json aclara en email y fullName de UserIngestDto que no se exigen en un tombstone: { externalId, tombstone: true } alcanza para desactivar al usuario.
Zona de peligro: borrar los datos de un tenant desde el panel
30/09/2026 Hasta ahora no habia forma de limpiar o resetear los datos de un tenant desde el panel. La nueva pagina Zona de peligro, siempre sobre un unico tenant, permite resetear el catalogo para una re-ingesta completa del ERP, purgar tipos de entidad enteros o borrar filas puntuales. Cada accion muestra antes que se pierde y que vuelve con el ERP, y las masivas piden escribir el identificador del tenant. Los borrados llegan a los telefonos como tombstones, incluidos los pedidos, que ahora tambien tienen borrado logico.
Nueva pagina Zona de peligro en el panel, por tenant: reset para re-ingesta del ERP (vacia el catalogo, los watermarks y las claves de idempotencia), purga por tipo de entidad y borrado fila por fila con busqueda. Cada accion queda en la auditoria del tenant.
Antes de ejecutar se muestra el impacto: filas afectadas, pedidos sin enviar al ERP (ventas que se pierden), ediciones de clientes pendientes y clientes bloqueados. Si los datos cambiaron desde la vista previa, no se ejecuta y se pide confirmar de nuevo.
Cada fila borrada en bloque recibe su propia marca de sincronizacion, para que ningun borrado se pierda entre paginas cuando se eliminan miles de registros a la vez.
Los pedidos tienen borrado logico: un pedido borrado viaja al telefono como tombstone y la app 0.4.1 lo elimina localmente. Volver a subir un pedido borrado no lo revive.
El vendedor puede dar de alta y editar clientes desde la app
27/09/2026 Hasta ahora un cliente nuevo solo nacia embebido en un pedido, y no habia forma de corregir un dato mal cargado sin pasar por el ERP. Ahora, detras de un interruptor por tenant apagado por defecto (a la espera de que el ERP integre el nuevo feed), el vendedor puede crear un cliente sin pedido y editar los datos que le pertenecen. Cada alta y edicion viaja al ERP por un feed propio, separado del de pedidos, con el mismo patron de ack/reject y recovery.
Descartar una edicion de cliente rechazada ahora restaura la ultima actualizacion del ERP que llego mientras el cliente estaba bloqueado, en lugar de revertir siempre al valor congelado antes del bloqueo (que podia quedar desactualizado si el ERP edito al cliente durante ese tiempo).
El panel admin muestra que clientes estan bloqueados por un cambio pendiente y cuales fueron rechazados por el ERP, con el motivo del rechazo.
El vendedor puede anular desde la app cualquier pedido que el ERP todavia no tomo; el pedido pasa a 'Anulado' y ya no se despacha. Un pedido ya tomado por el ERP no se puede anular desde el telefono.
El ERP recibe las altas y ediciones de clientes por un feed propio, GET /erp/v1/dispatch/clients, con el mismo ciclo de ack/reject/recovery que el de pedidos, y un lock de ingesta que evita pisar un cambio en vuelo.
El vendedor puede dar de alta un cliente sin pedido y editar los clientes que le pertenecen desde la app, detras del interruptor por tenant clients.authoring (apagado por defecto). Cada cambio se identifica por changeId y es idempotente ante reintentos.
Nueva lista de precios por defecto y dias de vencimiento del borrador rechazado, configurables por tenant en Configuracion. Sin lista por defecto, el alta standalone se bloquea con un mensaje claro en lugar de crear un cliente sin precios.
Un cliente confirmado por el ERP pero guardado sin cambios de fila (mismo valor de siempre) ya no queda bloqueado para siempre: la ingesta acepta un flag cycleComplete que marca el final de un ciclo completo de lectura de clientes, y libera por ausencia a todo cliente que ese ciclo no trajo, revirtiendolo a la ultima fila que el ERP le mando alguna vez. Reemplaza la obligacion de invalidar un hash a mano tras confirmar un cambio.
El CUIT y el CUIL se validan con su digito verificador
24/09/2026 Hasta ahora un CUIT o CUIL se validaba solo por su largo, y un digito mal tipeado conservaba los 11 digitos y pasaba. Rio abajo, el ERP reemplaza en silencio un numero invalido por el CUIT generico 20111111112, asi que un cliente real terminaba con un documento que no es de nadie. Ahora se verifica tambien el digito verificador, tanto en la ingesta del ERP como en el alta de clientes desde la app.
Un CUIT o CUIL declarado se valida con el digito verificador (modulo 11). Un numero con un digito cambiado se rechaza con Validation.InvalidDocumentNumber, el mismo codigo que el error de largo. El DNI no cambia.
El formulario de cliente nuevo de la app avisa en el momento que el digito verificador es invalido, en lugar de que el pedido rebote al sincronizar. Requiere la app 0.3.1.
El contrato y la guia de integracion describen la regla y piden contar los documentos que fallan antes de empezar a enviar documentType.
Cada tenant decide si sus clientes son de todos o de cada vendedor
24/09/2026 Hasta ahora cada telefono descargaba todos los clientes del tenant, con CUIT, telefono, coordenadas y saldo, porque nada decia a que vendedor pertenecia cada uno. Ahora el ERP informa el vendedor asignado de cada cliente, y cada tenant elige en Configuracion entre dos politicas. Con 'todos', nada cambia. Con 'por vendedor', cada telefono recibe solo sus clientes y los que no tienen vendedor asignado. Si el ERP reasigna un cliente, desaparece del telefono del vendedor anterior. Ademas, cada pedido registra que vendedor lo tomo, aunque el telefono sea compartido, y el ERP lo recibe en el despacho.
Cada pedido guarda el vendedor que lo tomo, marcado en el telefono al confirmarlo, y el despacho al ERP lo informa en createdByPreventistaExternalId. Un pedido de un cliente reasignado mientras estaba sin conexion se acepta igual.
Con la politica 'por vendedor', los clientes, las rutas y las paradas se sincronizan solo hacia su vendedor. Un cliente reasignado se borra del telefono del vendedor anterior, sin enviarle datos de clientes ajenos.
La ingesta de clientes acepta preventistaExternalId, el vendedor asignado. Omitirlo o mandarlo vacio deja al cliente sin vendedor. Los clientes creados desde la app quedan asignados a quien los creo hasta que el ERP los reasigne.
El panel marca los clientes asignados a un vendedor que no existe en el tenant, y los pedidos cuyo vendedor no coincide con el asignado al cliente en ese momento.
Nueva politica de visibilidad de clientes por tenant, en Configuracion: 'todos' (por defecto, el comportamiento de siempre) o 'por vendedor'. Al cambiarla, los telefonos reciben o pierden los clientes y rutas afectados en la siguiente sincronizacion.
La validacion de documentos publicada era la vieja
08/09/2026 La version anterior publico en el contrato una regla que el codigo ya habia reemplazado: decia que el documento de un cliente se valida contra un umbral, cuando en realidad se exige el largo exacto segun el tipo declarado. La diferencia importa justo en el caso que motiva la regla: un CUIT tipeado con un digito de mas supera cualquier umbral, y es lo que produce un error de tipeo. Ningun test cubria ese caso, asi que umbral y largo exacto eran indistinguibles para la suite y el texto viejo sobrevivio.
Se cubre el CUIT con un digito de mas, el numero de 11 digitos declarado como DNI, y la condicion que exige CUIT enviada sin tipo de documento. Sin el primero, un umbral y un largo exacto se comportan igual ante la suite.
El contrato y la guia describen la validacion real del documento de un cliente: digitos sin espacios, 11 para CUIT y CUIL, 7 u 8 para DNI. No es un umbral. Quien dimensionaba su carga con el texto anterior contaba de menos.
Se documenta que omitir el tipo de documento nunca se rechaza y desactiva las dos reglas que dependen de el. Sin eso, un integrador podia concluir que no puede enviar la condicion frente al IVA sin el tipo, y retener el campo que si tiene origen.
El contrato de integracion dice sus propias reglas
07/09/2026 Las reglas de cada campo del contrato de integracion vivian en comentarios del codigo fuente que el compilador descartaba: quedaban escritas, se leian en las revisiones, y no llegaban a ninguna parte. El archivo que un ERP realmente lee publicaba los campos sin una sola explicacion. Ahora esos comentarios se generan dentro del contrato, y escribir uno que no llegue pasa a romper la compilacion en vez de pasar inadvertido. Al encenderlo aparecieron tres afirmaciones falsas y varias referencias a tipos que ya no existen, todas heredadas de cambios anteriores.
Dos verificaciones nuevas sobre el contrato publicado: que ninguna descripcion tenga sangria, y que la regla de la condicion frente al IVA este escrita ahi.
Las descripciones del contrato ya no se renderizan como bloques de codigo en Swagger, Redoc ni Postman. Una frase partida en varias lineas llegaba con sangria y esos visores la mostraban como un bloque gris.
El contrato ERP publica la regla de cada campo documentado. La primera que gana con esto es la del cliente: omitir su condicion frente al IVA significa NO SE y nunca consumidor final. Esa regla existia solo en el codigo fuente.
Se corrigen tres afirmaciones falsas del contrato que sobrevivieron a la version anterior: dos describian el discriminador de impuestos ya eliminado, y una decia que el codigo de IVA de un producto no se verifica al cargarlo, cuando desde 1.3.0 se verifica.
La guia de integracion advierte que generar DTOs leyendo solo el arreglo required produce un cliente roto: los campos obligatorios solo al dar de alta no pueden figurar ahi, porque invalidarian una baja legitima.
Un solo IVA por producto, obligatorio y verificado
07/09/2026 Los impuestos se simplifican a lo que el negocio realmente usa: cada producto declara su alícuota de IVA, esa alícuota es obligatoria y se verifica contra los impuestos del tenant al cargar el catálogo. Desaparecen la distinción entre IVA e impuestos al pie del comprobante, el código de IVA por defecto de la empresa y el piso del 0%. Esa cadena de alternativas existía para sostener impuestos al pie que ningún cliente pidió, y su costo era que un producto mal configurado cotizaba al 0% sin que nada fallara — indistinguible de una exención real. Ahora un producto sin IVA que resuelva se rechaza al cargarlo, y un pedido que lo incluya se rechaza también. El error aparece cuando se carga el catálogo, no cuando el vendedor está frente al cliente. Si mañana hacen falta impuestos al pie, se agregarán con el caso real adelante.
El código de IVA de un producto pasa a ser obligatorio al cargarlo desde el ERP, y se verifica que exista como impuesto activo del tenant. Un producto sin IVA, o con uno que no existe, se rechaza en lugar de entrar y cotizar al 0%.
Consecuencia del punto anterior: los impuestos se cargan ANTES que los productos. Es la única dependencia de orden entre recursos, y reemplaza toda la cadena de alternativas anterior.
Un impuesto que productos activos estén usando ya no se puede borrar ni desactivar. Sin esta regla bastaba darlo de baja después para dejar productos huérfanos y que sus pedidos empezaran a fallar sin que nadie tocara un producto.
Se elimina el código de IVA por defecto por empresa, junto con su campo en Configuración. La alícuota de un producto es la suya y solo la suya.
Un pedido que incluya un producto sin una alícuota que resuelva se rechaza, en lugar de cotizar esa línea al 0%. Solo puede pasar con catálogos cargados antes de esta versión; el panel los marca en la pantalla Productos.
La guía de integración documenta la nueva regla, la única excepción a que ninguna referencia entre recursos se valida, y por qué esa excepción es justamente el IVA.
Se elimina la distinción entre IVA (por línea) e impuestos al pie del comprobante: todo impuesto es una alícuota de IVA. Ningún tenant tenía impuestos al pie en uso.
La configuración por tenant vuelve a abrir
03/09/2026 Guardar la configuración de un tenant que tenía algún dato de correo cargado pero sin puerto dejaba esa pantalla inaccesible: al volver a entrar daba error 500, y la pantalla que servía para deshacer el valor era justamente la que dejaba de abrir. El guardado sí se había grabado; lo que fallaba era volver a leerlo. La causa era una lectura del puerto que no toleraba el valor vacío, y el mismo defecto afectaba al envío de correo de ese tenant. Configurar el código de IVA por defecto no tenía nada que ver: era simplemente lo que se estaba editando cuando ocurrió.
Primeros tests del guardado y relectura de la configuración por tenant, incluido guardar dos veces seguidas. No había ninguno: el guardado y la lectura solo se ejercitaban por separado, y discrepaban sobre cómo se representa un número ausente.
Envío de correo: un tenant en ese mismo estado hacía fallar el envío en lugar de informar que la configuración de correo estaba incompleta.
Todas las lecturas numéricas de la configuración pasan por un único lugar que verifica el tipo antes de leer, en vez de repetir el chequeo en tres archivos donde a dos les faltaba.
Configuración por tenant: la pantalla vuelve a abrir cuando hay datos de correo cargados sin puerto. Antes devolvía error 500 al entrar y también al guardar de nuevo, dejando al tenant sin forma de corregirlo desde el panel.
Esta lista vuelve a estar completa, y ya no se puede publicar una versión sin anunciarla
03/09/2026 Las versiones 1.1.0 y 1.2.0 se desplegaron correctamente y ninguna de las dos apareció acá: esta lista se escribe a mano y nadie agregó sus entradas. El problema no era que faltaran datos, era que faltaban en silencio — la lista no avisa que hay un hueco, simplemente termina en la última versión que alguien recordó cargar, y eso se lee como "no salió nada más". Este release agrega las dos entradas faltantes y, sobre todo, hace que publicar una versión sin su entrada falle antes de construir nada. La 1.0.4 ya había eliminado este mismo paso manual para el número de versión; quedaba en pie el de al lado.
Publicar una versión que no tenga su entrada en esta lista ahora falla la publicación antes de construir las imágenes, en lugar de desplegar correctamente y dejar esta pantalla mostrando una versión vieja como la más reciente.
Se agregaron las entradas de las versiones 1.1.0 y 1.2.0, que se habían desplegado sin anunciarse acá.
Alta de cliente desde la calle y los impuestos dicen cómo se aplican
03/09/2026 El preventista ya puede registrar un cliente nuevo sin salir del pedido: hasta ahora un pedido para un cliente que no estaba en el catálogo era un callejón sin salida. El cliente se crea junto con ese primer pedido y viaja al ERP para que lo registre. Los datos del cliente solo se usan cuando el cliente no existe; si ya está en el catálogo, el catálogo manda y lo que venga del teléfono se ignora. Además, el listado de impuestos ahora indica si cada alícuota se aplica por línea o se suma al pie del comprobante, que es lo que decide cuánto paga el cliente y hasta ahora no se veía en ninguna pantalla. Este release NO cambia ningún impuesto cargado: reclasificarlos sigue siendo una acción sobre datos desde la ingesta del ERP.
El número de documento se valida por longitud exacta (CUIT y CUIL 11 dígitos, DNI 7 u 8) y nunca se recorta. Si el vendedor tipea 12 dígitos, el formulario avisa que van 12 en lugar de guardar un número de 11 que parece válido y no lo es.
El identificador del cliente creado en el teléfono se acuña una sola vez y nunca se vuelve a acuñar: el identificador de un cliente nace en quien lo crea. El ERP confirmó que el formato entra en su columna sin recortarse.
Un pedido puede traer los datos de un cliente que todavía no existe, y el cliente se da de alta al guardarlo. Si el cliente ya está en el catálogo, esos datos se ignoran: un teléfono nunca puede pisar información que administra el ERP.
Catálogo → Impuestos: nueva columna que indica si el impuesto se aplica por línea (IVA) o se suma al pie del comprobante. Dos alícuotas con porcentajes correctos pero mal clasificadas se veían idénticas a dos alícuotas correctas.
Nuevos datos del cliente: nombre de fantasía, condición frente al IVA, tipo y número de documento, y código postal. Todos opcionales a propósito: no informar la condición de IVA no significa "consumidor final", significa que no se informó.
El cliente creado así viaja al ERP dentro del despacho del pedido, marcado como "este pedido trajo a su cliente", para que el ERP lo registre.
El tipo de impuesto pasa a ser obligatorio y la guía de integración deja de enseñar un error de 100x
03/09/2026 Al dar de alta un impuesto desde el ERP ahora es obligatorio declarar si es IVA o un impuesto de pie. Antes, omitirlo lo convertía en impuesto de pie en silencio: una alícuota de IVA sin tipo terminaba sumando al pie de todos los comprobantes en lugar de aplicarse por producto, y con una sola alícuota los totales coincidían por casualidad. Se corrigió además la guía de integración, que documentaba las alícuotas como porcentaje (21.0) cuando el sistema siempre las interpretó como factor decimal (0.21): quien siguiera el ejemplo estaba calculando 2100%. Ese error estaba en la guía desde el primer día y nunca fue una regresión. Ningún integrador había implementado todavía la carga de impuestos, así que exigir el tipo ahora no rompió ninguna integración en uso.
Catálogo → Productos: el aviso de productos sin IVA ahora distingue si el producto no tiene código de IVA o si tiene uno que no resuelve. El indicador y el motivo se calculaban por separado y podían contradecirse; ahora el indicador se deriva del motivo, así que no pueden divergir. La vista de detalle informaba "sin problemas" por omitir el dato.
Un test toma una base real en el esquema anterior, inserta impuestos con esa forma vieja, migra y calcula un pedido completo, para demostrar que la migración no altera la aritmética de ningún pedido existente en lugar de solo verificar que clasifica bien.
La carga de impuestos desde el ERP rechaza un impuesto que no declare su tipo ('vat' o 'document'). Omitirlo ya no se interpreta como impuesto de pie. Las bajas quedan exentas: borrar un impuesto por su código nunca vuelve a declarar su clasificación.
La guía de integración documentaba las alícuotas como porcentaje (21.0) cuando siempre se interpretaron como factor decimal (0.21). Corregido, junto con otras tres divergencias, y ahora la guía declara que ante una contradicción manda el contrato generado desde el código.
La versión que informa el sistema vuelve a coincidir con la desplegada
01/09/2026 La versión 1.0.3 se desplegó correctamente, pero el sistema seguía informando 1.0.2 al consultarlo: el número que llevaban las imágenes se mantenía en un archivo aparte que había que actualizar a mano en cada release. Ese paso manual se eliminó — la versión ahora se toma de la etiqueta del release al construir las imágenes. Este cambio no altera ninguna funcionalidad: lo único que corrige es el número informado.
La versión informada por el sistema se toma de la etiqueta del release al construir las imágenes, en lugar de un archivo que debía actualizarse manualmente. La 1.0.3 informaba 1.0.2 por ese motivo.
La verificación automática posterior al despliegue ahora confirma que el servicio responde con la versión recién publicada. Antes solo comprobaba que el sistema estuviera en línea, algo que la versión anterior ya cumplía mientras la nueva todavía se estaba iniciando.
IVA por producto y contrato de integración ERP generado desde el código
01/09/2026 Los impuestos ahora distinguen entre IVA (una alícuota por producto, excluyente) e impuestos que se suman al pie del comprobante. Cada línea de un pedido resuelve su IVA en este orden: el código de IVA propio del producto, el código de IVA por defecto de la empresa, o 0% si ninguno aplica. Instalar esta versión no cambia el total de ningún pedido existente: todos los impuestos actuales quedan clasificados como impuestos de pie, por lo que la aritmética se mantiene idéntica a la 1.0.2. Los totales solo cambian si alguien reclasifica deliberadamente un impuesto como IVA desde la ingesta del ERP, una acción sobre datos, reversible, que no requiere un nuevo despliegue. La vista previa de IVA por línea en la app del preventista se distribuye por separado y no forma parte de este release.
El contrato de integración ERP ahora se genera a partir del código y se valida en cada build, para que la documentación nunca quede desactualizada respecto de la API real. El contrato deja explícito que una respuesta HTTP 200 puede significar que el lote completo de todos modos falló.
Configuración por empresa: nuevo campo para definir el código de IVA por defecto que se aplica a los productos que no tienen uno propio.
Catálogo → Productos: nueva columna IVA en el listado, y un aviso cuando hay productos activos sin un código de IVA que resuelva, con la cantidad afectada, un filtro para verlos y la alícuota a la que se están cotizando en la práctica (la de la empresa, o 0% si tampoco está configurada).
Cada línea de un pedido resuelve su IVA en este orden: código de IVA propio del producto → código de IVA por defecto de la empresa → 0% si no hay ninguno configurado. El ERP puede asociar un código de impuesto a cada producto al ingestarlo.
Los impuestos ahora tienen un tipo: IVA (una alícuota por producto, las alícuotas de IVA son excluyentes entre sí) o impuesto de pie (se acumula al total del comprobante). El ERP puede indicar el tipo al dar de alta un impuesto.
Corregido: no se podía iniciar sesión en el panel
23/08/2026 Se corrigió una falla real de producción: la página de login no renderizaba y nadie podía iniciar sesión en el panel. Ahora la página se sirve de forma estática, sin depender de la conexión en vivo del circuito. También se renombraron los dominios públicos a preventa-api y preventa-panel.
Los hostnames públicos se renombraron a preventa-api y preventa-panel.
La página de login ahora se renderiza de forma estática en lugar de depender de un circuito de Blazor Server ya conectado. Antes, si el circuito no llegaba a establecerse a tiempo, la página quedaba en blanco y era imposible iniciar sesión.
Panel Admin endurecido antes de exponerlo públicamente
21/08/2026 El panel Admin se reforzó antes de recibir un dominio público, y la API pasó a publicarse en preventa.dotarsoluciones.cloud. También se corrigieron el servicio de migraciones (que no debía ejecutarse sin argumentos), la resolución de la IP del cliente detrás del proxy de confianza, y la documentación del túnel de acceso al Admin, que apuntaba a un puerto sin nada escuchando.
La documentación del túnel de acceso al Admin apuntaba a un puerto en el que no había nada escuchando; corregida.
El panel Admin se endureció antes de recibir un hostname público (autenticación y superficie expuesta revisadas).
Corregida la resolución de la IP real del cliente cuando la petición llega a través del proxy de confianza.
El servicio de migración ya no se ejecuta accidentalmente sin argumentos durante el despliegue.
La API pública ahora se sirve desde preventa.dotarsoluciones.cloud.
Primer release en la infraestructura de producción
21/08/2026 El sistema se mudó del entorno de laboratorio en la oficina al servidor de producción, y la numeración de versiones se reinició en 1.0.0 junto con el nuevo proceso de release: cada versión ahora se construye como una imagen inmutable a partir de un tag de git y se despliega mediante Coolify. Las entradas de este historial anteriores a esta usan la numeración de la etapa de laboratorio y no son más nuevas que las que siguen a partir de acá.
El despliegue ejecuta las migraciones de base de datos antes de publicar cada release.
Correcciones al pipeline de integración continua.
Nuevo proceso de release: cada tag de git construye una imagen inmutable en GHCR y Coolify la despliega mediante docker-compose.
El hostname de la API se movió al servidor en la nube.
Se eliminó el túnel de Cloudflare usado por el entorno de laboratorio anterior.
Banners Promo y Rutas editables desde el panel
16/07/2026 Los Banners Promo y las Rutas ahora se pueden crear, editar y eliminar desde el catálogo del panel (antes eran solo lectura del ERP). Los banners creados desde el panel llegan a la sección Novedades de la app del preventista en la próxima sincronización. También se corrigió un defecto de sincronización: al reemplazar las paradas de una ruta, los dispositivos ya sincronizados no se enteraban de las paradas eliminadas.
Catálogo → Rutas: alta, edición y baja desde el panel, con editor de paradas ordenado (agregar, quitar, reordenar). Se valida una ruta por preventista por día y la unicidad del código. Origen ERP/Panel visible por fila.
Catálogo → Banners Promo: alta, edición y baja desde el panel (título, cuerpo, severidad, orden, vigencia). Estos banners alimentan la sección "Novedades" del home de la app. Los registros provenientes del ERP muestran una advertencia: la próxima sincronización puede sobrescribir cambios manuales.
Sincronización: al reemplazar las paradas de una ruta (desde el panel o el ERP), las paradas eliminadas ahora se marcan como borradas de forma visible para los dispositivos ya sincronizados, en lugar de desaparecer silenciosamente y quedar huérfanas en la app.
Corrección: el panel ya no queda con archivos viejos tras una actualización
16/07/2026 Se corrigió la causa del error "deamTracking.setClientMarkers is not a function" al tocar "Mostrar clientes" justo después de una actualización: el navegador seguía usando una versión vieja de los archivos del panel. Las direcciones de los archivos ahora cambian automáticamente con cada modificación real, por lo que ya no hace falta refrescar con Ctrl+F5 después de un deploy.
Los archivos estáticos del panel (JS/CSS) ahora usan huellas de contenido en la URL: cada deploy con cambios fuerza la descarga fresca en navegadores y CDN. El mecanismo anterior (versión del ensamblado) no variaba entre deploys porque el build de Docker no incluye el historial de git.
Clientes en el mapa de Tracking, filtro con hora y nuevo formato de fecha
15/07/2026 El mapa de Tracking ahora puede mostrar los clientes de la empresa como marcadores para ver el recorrido del preventista en contexto, y el filtro del recorrido histórico permite acotar por hora además de por fecha. El formato de fecha predeterminado del sistema pasa a ser dd/MM/yyyy (cada empresa puede seguir eligiendo el suyo en su Localización).
Si hay clientes sin ubicación cargada, el panel de Tracking indica cuántos no se muestran en el mapa. Al cambiar de tenant la capa de clientes se apaga para no mezclar datos.
Tracking: nuevo botón "Mostrar clientes" en el panel del mapa. Dibuja los clientes activos de la empresa como marcadores naranjas (distintos de los preventistas en vivo), con código y nombre al tocarlos. "Encuadrar todos" ahora abarca también a los clientes visibles.
Tracking → Recorrido histórico: los filtros Desde/Hasta ahora aceptan hora además de fecha (formato 24 h). Sin hora indicada se mantiene el día completo, como antes.
El formato de fecha predeterminado del sistema ahora es dd/MM/yyyy en todo el panel. Las empresas con formato propio configurado no ven cambios.
Mapa de Tracking expandible y formato de fecha en los filtros
15/07/2026 La pantalla de Tracking ahora permite expandir el mapa para que ocupe todo el marco de la página (manteniendo el menú y la barra superior), con un botón para volver a la vista normal. Además, los selectores de fecha del recorrido histórico ahora muestran las fechas con el formato configurado para la empresa.
Tracking: nuevo botón "Expandir mapa" junto a "Encuadrar todos". El mapa ocupa todo el ancho y alto disponible de la página, el panel de preventistas conectados flota sobre el mapa y un botón "Restaurar vista" vuelve al diseño normal.
Tracking → Recorrido histórico: los campos Desde/Hasta mostraban la fecha en formato MM/dd/yyyy; ahora usan el formato de fecha configurado en la Localización de la empresa.
Formato de fecha y hora: configuración consolidada por empresa
15/07/2026 El formato de fecha y hora ahora se configura únicamente desde la sección Localización de cada empresa: se eliminó la página Configuración general introducida en 1.34.0. Si la empresa no define un formato, se usan los predeterminados del sistema (yyyy-MM-dd / HH:mm). También se corrigió un defecto visual en los selectores de formato.
Corregido: la etiqueta de los selectores de formato quedaba superpuesta al texto de la opción seleccionada cuando estaba elegido el valor por defecto.
Configuración por empresa → Localización: los formatos de fecha y hora se definen acá; la opción por defecto ahora se muestra como "Predeterminado (yyyy-MM-dd)" / "Predeterminado (HH:mm)". Se eliminó la página Configuración general del menú.
Formato de fecha y hora configurable
15/07/2026 El formato con el que el panel muestra fechas y horas ahora es configurable. Se agregó una nueva página "Configuración general" con la sección Localización donde se define el formato a nivel sistema (por ejemplo dd-MM-yyyy en lugar de yyyy-MM-dd), y cada empresa puede definir su propio formato en su configuración o heredar el general. Todas las pantallas del panel respetan el formato elegido.
Nueva página Configuración general (menú lateral): formato de fecha y de hora a nivel sistema, elegidos de una lista de formatos válidos con vista previa en vivo (dd-MM-yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd; HH:mm, HH:mm:ss, hh:mm tt).
Todas las pantallas del panel (Pedidos, Tracking, Visitas, Auditoría, Logs, Catálogo, etc.) muestran fechas y horas con el formato configurado. La configuración de la empresa también se expone resuelta a la app del preventista vía /api/v1/tenant/config.
Configuración por empresa → Localización: formato de fecha y de hora propios del tenant, con opción de heredar la configuración general (comportamiento por defecto). Resolución: tenant → general → yyyy-MM-dd / HH:mm.
Recorrido histórico de preventistas en el mapa
14/07/2026 La pantalla de Tracking ahora permite dibujar sobre el mapa el recorrido real que hizo un preventista: se elige el preventista y un rango de fechas (interpretado en la zona horaria de la empresa) y se traza la ruta con sus puntos de inicio y fin, incluyendo las paradas de visita (check-in/check-out). También se mejoró el selector de zona horaria para que siempre muestre la lista completa al abrirlo.
Configuración → Zona horaria: el desplegable ahora muestra la lista completa de zonas al abrirlo aunque ya haya una seleccionada; escribir filtra la lista.
Tracking → Recorrido histórico: selector de preventista + rango de fechas + botón "Dibujar recorrido". El trayecto se arma con los geo-pings reales almacenados (cada ~15 segundos en movimiento), con marcadores de inicio y fin y las paradas de visita. Rangos de hasta 31 días; los recorridos muy largos se simplifican automáticamente para mantener el mapa fluido.
Zona horaria por empresa — el panel muestra las horas en tu zona
14/07/2026 Todas las fechas y horas del panel Admin ahora se muestran en la zona horaria configurada para la empresa (antes se mostraban en UTC: un pedido de las 06:35 aparecía como 09:35). La zona se elige desde un buscador con todas las zonas del mundo, con la zona del servidor como valor por defecto. También se corrigió el cálculo de la "ruta del día" del preventista, que cortaba el día a medianoche UTC en lugar de la medianoche local.
Todas las pantallas del panel (Pedidos, Tracking, Visitas, Auditoría, Logs, Catálogo, etc.) muestran fechas y horas convertidas a la zona horaria de la empresa. El valor por defecto es la zona horaria del servidor (America/Argentina/Buenos_Aires en el despliegue actual) en lugar de UTC.
La "ruta del día" del preventista ahora se resuelve usando el día calendario en la zona horaria de la empresa. Antes el corte era la medianoche UTC: un preventista trabajando después de las 21:00 (hora argentina) ya no encontraba la ruta de su día.
Configuración → Zona horaria: reemplazado el campo de texto libre por un buscador con todas las zonas horarias del sistema (formato IANA, ej. America/Argentina/Buenos_Aires). El servidor valida que la zona exista antes de guardar.
API keys ERP — el secret vuelve a mostrarse al crear una key
14/07/2026 Al crear una API key ERP desde el panel, el secret generado no se estaba mostrando: el refresco de la tabla lo borraba antes de renderizarlo, dejando la key inutilizable (el secret se entrega una única vez). Ahora el aviso con el secret aparece correctamente después de crear la key.
Panel Admin, página API keys ERP: el recargado de la lista tras crear una key limpiaba el secret recién generado antes de mostrarlo. El secret ahora solo se limpia al cambiar de tenant, y el alerta "cópialo ahora" aparece siempre tras la creación.
App móvil apuntando a producción y login corregido en instalaciones nuevas
14/07/2026 La app del preventista ya se distribuye apuntando a la URL pública de producción. Se corrigió un error que impedía iniciar sesión en instalaciones nuevas: la app enviaba un código de empresa de demostración invisible para el usuario, y el servidor rechazaba el acceso con "No se pudo resolver el tenant" aunque el email y la contraseña fueran correctos.
App móvil: cuando el servidor no encuentra la empresa asociada, el mensaje ahora es "No se encontró la empresa asociada a tu usuario. Contactá al administrador." en lugar de la jerga técnica "No se pudo resolver el tenant".
App móvil: eliminado el precargado de credenciales demo en instalaciones sin usuarios guardados. El campo de empresa no es visible en la pantalla de login, así que el código "demo" precargado era imposible de corregir y bloqueaba el acceso en producción. Ahora la empresa se resuelve automáticamente con el email y la contraseña (pre-login).
Panel Admin: en el diálogo de vincular usuario a otros tenants, el tenant actual ahora se muestra como "Ya vinculado (origen)" en lugar de "Origen".
App móvil: la URL del servidor se configura por archivo de entorno en el build (env/dev.json y env/prod.json via --dart-define-from-file); el APK de producción apunta al dominio público del túnel.
Tracking en tiempo real — corregido el error 500 del panel
13/07/2026 La pantalla de Tracking en tiempo real ya no da error 500. El panel Admin no tenía configurada la clave de firma de tokens que necesita para conectarse al canal de posiciones en vivo; ahora la comparte con la API y el mapa abre correctamente.
El proceso Admin ahora recibe la misma clave de firma JWT (y issuer/audience) que la API. La página de Tracking emite un token de administrador para conectarse al hub SignalR en tiempo real; sin la clave, ese paso lanzaba una excepción y la página devolvía HTTP 500. Es la única pantalla del panel que emite un token, por eso era el único lugar donde se manifestaba.
docker-compose: el servicio admin recibe DeamPreventa__Jwt__SigningKey (prod desde .env, dev desde el override) y los Issuer/Audience explícitos, en simetría con el servicio api, para que el token que emite el Admin sea aceptado por la validación del hub.
Acceso externo — limpieza del DNS al desactivar la exposición del Admin
13/07/2026 Al desactivar la exposición del panel Admin, ahora se elimina también el registro DNS del subdominio del Admin, que antes quedaba huérfano en Cloudflare. La limpieza es best-effort: apagar la exposición nunca falla por este paso (el panel deja de estar accesible de inmediato al quitarse la regla de ingress).
La limpieza del DNS es best-effort y se ejecuta solo si la republicación del ingress fue exitosa: si Cloudflare no está disponible, la exposición igual queda desactivada y el borrado se reintenta en el próximo intento, sin bloquear el toggle.
Al desactivar la exposición del Admin, el aprovisionamiento borra el registro DNS (CNAME) del hostname del Admin una vez removida la regla de ingress. Antes solo se quitaba el ingress (el subdominio dejaba de resolver a la app) pero el registro DNS persistía colgando en Cloudflare.
Acceso externo — exposición del panel Admin
13/07/2026 Ahora exponer el panel Admin por el túnel funciona de punta a punta: al activarlo se crea el DNS del subdominio del Admin y se re-aplica la configuración aunque el túnel ya esté conectado. Antes el subdominio del Admin quedaba sin resolver (NXDOMAIN).
Al exponer el panel Admin, el aprovisionamiento crea el registro DNS (CNAME) del hostname del Admin apuntando al túnel. Antes solo se creaba el de la API, por lo que el subdominio del Admin quedaba en NXDOMAIN aunque la regla de ingress estuviera publicada.
Activar/desactivar la exposición del Admin (o cambiar hostnames) ahora re-aplica DNS + ingress aunque el túnel ya esté conectado. Antes, con el túnel en estado 'completado', el paso se salteaba y el cambio no tenía efecto.
Acceso externo (Cloudflare Tunnel) — robustez y fixes de despliegue
13/07/2026 Se endureció el módulo de Acceso Externo: el aprovisionamiento sobrevive a los redeploys sin perder las credenciales, la página ya no se cae si algo falla, y los errores del API Token de Cloudflare se explican de forma clara. Si al actualizar el panel pide recargar el API Token, es esperado: reingresalo una vez y queda persistente.
Los rechazos de token de Cloudflare que llegan como HTTP 400 con 'Authentication failed' ahora se clasifican como error de autenticación (antes caían en el genérico 'falló dos veces' con instrucciones equivocadas).
El token del connector de cloudflared se escribe en /data/cloudflared (volumen compartido) desde el proceso Admin, que es el que aprovisiona. Antes se escribía en una ruta local que el sidecar nunca leía, por lo que el túnel se creaba pero no conectaba.
La página de Acceso Externo ya no devuelve 500 cuando el token guardado no se puede descifrar: muestra un mensaje claro para reingresar el API Token.
Crear / Reconectar y Rotar ya no tiran abajo el circuito de Blazor ante un token ilegible; devuelven un error manejable con instrucciones en lugar de romper la página.
docker-compose fija DataProtectionKeysPath y CloudflaredTokenDirectory en los servicios api y admin, apuntando al volumen compartido deam-data (un único key ring persistente vía SetApplicationName).
Las claves de cifrado (Data Protection) ahora persisten en el volumen compartido /data/dp-keys. Antes vivían en una ruta interna del contenedor y se borraban en cada deploy, dejando ilegibles todos los secrets cifrados (token de Cloudflare, SMTP, ERP).
F8.35 — Catálogo demo sembrado en arranque (clientes + productos + ruta del día)
02/06/2026 DemoCatalogSeederHostedService que siembra el tenant 'demo' con datos demo-friendly al arrancar (Development + DemoSeed:Enabled + DemoSeed:SeedCatalog). Idempotente: skip si el tenant ya tiene categorías. Usa las strategies de ingest reales (audit trail correcto, ChangedAtTicks poblado, sync down sirve la data tal cual). 25 clientes en barrios reales de CABA con coordenadas, 40 productos en 8 categorías con marcas argentinas (Coca-Cola, La Serenísima, Quilmes, etc.), precios en ARS, 1 depósito DEP-CENTRAL con stock variado, IVA 21%/10.5%, 3 métodos de pago (efectivo/débito/cuenta corriente), ruta del día para el preventista demo (ExternalId auto-asignado a PREV-DEMO-1) con 8 paradas, 3 banners promocionales. Tests apagan SeedCatalog via TestWebAppFactory para mantener el supuesto de tenant vacío.
Datos demo: 25 clientes en barrios reales de CABA (Recoleta/Palermo/Belgrano/Caballito/Almagro/Boedo/Villa Crespo/San Telmo/La Boca/Barracas/Flores/Liniers/Devoto/Núñez/Microcentro/Once/Parque Chacabuco/Villa Urquiza/Coghlan) con direcciones reales, coordenadas, teléfonos, zonas, balance variable y payment method asignado.
Idempotencia: chequea db.Categories.AnyAsync() antes de sembrar. Si el tenant ya tiene catálogo, retorna sin tocar nada. Para resembrar: borrar data/tenants/demo-tenant.db (o data/deam.system.db para reset completo) y reiniciar el API.
1 lista de precios (DEFAULT, ARS), 1 depósito (DEP-CENTRAL), 2 taxes (IVA 21% / IVA 10.5%), 3 métodos de pago (Efectivo / Débito / Cuenta Corriente 30 días). 3 banners promocionales (Success: 30% off lácteos, Highlight: Coca 2x1, Warning: cierre por feriado).
Asignación de ExternalId='PREV-DEMO-1' al usuario demo@deam.local — permite que GET /api/v1/route/today retorne la ruta sembrada (que apunta a PreventistaExternalId='PREV-DEMO-1'). 1 ruta del día (UTC al mediodía) con 8 paradas que recorren CABA Centro→Recoleta→Palermo→Belgrano→Caballito.
DemoCatalogSeederHostedService ([Hosting/DemoCatalogSeederHostedService.cs](src/DEAM.Preventa.Infrastructure/Hosting/DemoCatalogSeederHostedService.cs)): registrado tras SystemDbInitializerHostedService y ChangelogSeederHostedService. Resuelve tenant via ITenantRegistry, setea ITenantContext manualmente y delega a IIngestService + strategies (CategoryIngestStrategy, ProductIngestStrategy, PriceIngestStrategy, etc.) — mismo path que el ERP real, audit trail correcto, ChangedAtTicks poblado por el AuditingInterceptor.
DemoSeedOptions.SeedCatalog (default true). Permite separar el seed del tenant/admin (que sigue controlado por DemoSeed.Enabled) del seed del catálogo. TestWebAppFactory setea SeedCatalog=false para que los tests sigan asumiendo tenant vacío sin tener que pisar Enabled=true (que es lo que provisiona el tenant + admin que los tests sí necesitan).
40 productos en 8 categorías (Bebidas/Lácteos/Almacén/Conservas/Snacks/Panadería/Fiambres/Limpieza) con marcas argentinas reales (Coca-Cola, Quilmes, La Serenísima, Sancor, Gallo, Matarazzo, Ledesma, Bimbo, Lays, etc.). Precios en ARS realistas, stock determinístico por hash del SKU (~15% low/35% mid/50% healthy para que los badges de stock se vean con variedad).
F8.34 — UI in-app de push notifications (banner foreground + deep-link tap)
01/06/2026 Cierra el último gap de UX de push: cuando el preventista tiene la app abierta y llega un push Critical/Warning, ahora ve un MaterialBanner persistente con icon+severity color + acción 'Ver'; los Info/Success aparecen como SnackBar momentáneo. Cuando toca un push (background o cold-start), navega al inbox con highlight del item (scroll + border animado). Backend: severity ahora se incluye en el FCM Data dict para que el cliente decida la UI sin tener que parsear el body.
TODO: 'UI in-app banner para foreground' y 'deep-link a /notifications/{id}' marcados completados como parte de F8.34. Pendientes futuros restantes para fase siguiente: job cleanup tokens muertos, badge count en home, sonido custom por severity, swipe to dismiss.
Backend: PushDispatchService.BuildData ahora incluye severity (Info/Success/Warning/Critical) en el Data dict del FCM payload. El cliente Flutter decide qué UI mostrar (MaterialBanner vs SnackBar) sin tener que inferir severity del body. Test PushDevicesTests actualizado para asertear el campo.
NotificationsScreen: ConsumerStatefulWidget con ScrollController + flag _scrolledToHighlight (idempotente — solo scrollea una vez). Cuando llega con ?highlight=, busca el item por serverId, anima scroll cerca del top con animateTo + Curves.easeOutCubic 380ms. Si no hay match (notification borrada o no sincronizada todavía), no hace nada — el user ve la lista normal.
Foreground handler (onMessage): lee severity del data dict. Critical/Warning → MaterialBanner persistente con icon+severity color + acciones 'Cerrar'/'Ver'. Info/Success → SnackBar 4s con acción 'Ver'. La acción 'Ver' navega a /notifications?highlight={notificationId}.
Flutter: scaffoldMessengerKey global en main.dart cableado al MaterialApp.router. Permite que el PushService muestre banners/snackbars desde fuera del árbol de widgets, sin depender de un BuildContext particular.
Router: GoRoute /notifications ahora pasa state.uri.queryParameters['highlight'] al constructor de NotificationsScreen. URL deep-link: /notifications?highlight=GUID.
Tap handlers (onMessageOpenedApp + getInitialMessage): navegan a /notifications?highlight={notificationId}. getInitialMessage cubre el cold-start desde un push (app cerrada → tap → app arranca con el payload disponible). _checkInitialMessage corre una sola vez en init().
_NotificationTile: nuevo flag highlighted. Cuando true, post-frame setea _highlightOn=true + Future.delayed 2.5s para apagarlo. AnimatedContainer transitions border (1px→2px en color de severity) + background (surface → severity tinted 8% opacity) con easeOutCubic 400ms. Visual subtle, no intrusivo.
PushService: nuevos colaboradores opcionales (messengerKey + navigate callback) inyectados via pushServiceCollaboratorsProvider. main.dart hace override del provider para wirearle scaffoldMessengerKey + (loc) => router.go(loc). El service no depende de go_router directamente — el callback es opaco, facilita mockear en tests.
F8.33 — Firebase FCM real + Flutter push integration
01/06/2026 Cierra el ciclo end-to-end de notifications. Backend: FirebaseAdmin 3.5 + FcmPushSender real reemplaza al LoggingPushSender stub; degrada graceful a stub mode si Push.Enabled=false o el service account JSON no existe (mismo comportamiento que el stub anterior — la app arranca sin Firebase configurado). Flutter: firebase_core + firebase_messaging + PushService con init try/catch + registerCurrentToken tras login + onTokenRefresh + foreground/background handlers + permission Android 13+ POST_NOTIFICATIONS + plugin Gradle Google Services condicional (se aplica solo si google-services.json existe). Patrón 'ready but not connected': el operador dropea los 3 archivos de credenciales (firebase-service-account.json + google-services.json + GoogleService-Info.plist) cuando quiera activar real, sin tocar código. Todo gitignored.
AndroidManifest.xml: permiso POST_NOTIFICATIONS para Android 13+ (API 33). Antes de Android 13 el permiso es declarativo y se concede al instalar; en 13+ requiere runtime request (que firebase_messaging.requestPermission dispara cuando corresponde).
DevicesApi + RegisterDeviceRequest/DeviceTokenSummaryDto + devicesApiProvider + pushServiceProvider en providers.dart. PushService es singleton — los listeners de Firebase son globales y no queremos múltiples instancias compitiendo. ref.onDispose llama shutdown().
TODO: 'Push notifications reales (FCM + APNs)' marcado completo a nivel infra. Pendiente futuro: job de cleanup de tokens muertos (>30 días LastSeenAtUtc o FCM UNREGISTERED); UI in-app banner para foreground messages; deep-link a /notifications/{id} en onMessageOpenedApp.
FcmPushSender degrada graceful a stub mode si: Push.Enabled=false (log INFO), FirebaseServiceAccountPath vacío (log WARN), archivo no existe (log WARN), o falla la inicialización (log ERROR con la excepción). En stub mode: Success=true con providerMessageId='stub-{guid}' — mismo comportamiento que LoggingPushSender, no rompe el trigger fire-and-forget. Permite arrancar el host sin Firebase configurado y desarrollo local sin internet.
Flutter: firebase_core 3.6 + firebase_messaging 15.1 agregados a pubspec.yaml. Plugin Gradle com.google.gms.google-services 4.4.2 declarado en android/settings.gradle.kts (apply false) y aplicado condicionalmente en app/build.gradle.kts solo si google-services.json existe — sin el archivo la build pasa, no se aplica el plugin, firebase_messaging falla en runtime con try/catch. Patrón 'ready but not connected'.
FcmPushSender envía PushMessage como Firebase Message con Notification (Title/Body) + Data dict (notificationId/relatedEntityType+Id) + AndroidConfig High priority + ApnsConfig default sound. Captura FirebaseMessagingException con MessagingErrorCode (UNREGISTERED, INVALID_ARGUMENT, etc.) y otros con Exception genérica — todos devuelven Success=false con código diagnosticable (los push son best-effort, nunca tira).
docs/FIREBASE_PUSH_SETUP.md reescrito como runbook de activación 3 partes: (1) crear proyecto Firebase + 3 archivos de credenciales; (2) backend ya cableado, solo dropear JSON + config; (3) Flutter ya cableado, solo dropear archivos nativos. Cuestiones operativas: token rotation, cleanup tokens muertos, no lock-in (cambiar IPushSender), iOS sin FCM.
pushBackgroundHandler top-level function con @pragma('vm:entry-point') registrado con FirebaseMessaging.onBackgroundMessage ANTES de runApp en main.dart. Permite procesar pushes con la app cerrada/terminated en un isolate separado. Hoy es no-op (el sistema muestra la notificación si el payload tiene notification.title/body) — el hook está listo para trabajo silencioso futuro (ej. actualizar badge offline).
Backend: FirebaseAdmin 3.5.0 agregado a Directory.Packages.props + Infrastructure.csproj. FcmPushSender ([Push/FcmPushSender.cs](src/DEAM.Preventa.Infrastructure/Push/FcmPushSender.cs)) reemplaza al LoggingPushSender stub en DI. Construye FirebaseApp.DefaultInstance idempotente con lock; lee credenciales del PushOptions.FirebaseServiceAccountPath via GoogleCredential.FromFile (CS0618 suprimido — el patrón es el oficial de Firebase Admin SDK).
.gitignore: firebase-service-account.json + google-services.json + GoogleService-Info.plist agregados. NUNCA commitear credenciales Firebase al repo. En CI/CD: cargar como secret y materializar en arranque (backend) o inyectar en el build (Android/iOS).
PushService ([core/push/push_service.dart](flutter_app/lib/core/push/push_service.dart)): init() envuelve Firebase.initializeApp() en try/catch — sin archivos nativos deja available=false y la app sigue funcionando. registerCurrentToken() pide permiso, obtiene token FCM y lo POSTea a /api/v1/devices/register (idempotente desde backend). onTokenRefresh re-registra automáticamente al rotar. Listeners onMessage (foreground, logueado) + onMessageOpenedApp (tap, logueado). shutdown() cancela suscripciones para logout.
Hooks: main.dart inicializa PushService en background tras bootstrap de auth (no bloquea UI); si hay sesión persistida, registra el token. login_screen.dart llama registerCurrentToken() tras login OK con unawaited — no bloquea navegación. Best-effort: si Firebase falla, app sigue funcionando.
F8.32 — Feature flags consumer Flutter (endpoint público + Drift cache + Riverpod)
01/06/2026 Cierra el ciclo end-to-end de feature flags. Endpoint público GET /api/v1/feature-flags con evaluación server-side (stableKey = AppUser.ExternalId con fallback al UserId.ToString) — el Flutter solo lee booleanos, no replica la lógica de bucket. Lado cliente: tabla Drift FeatureFlagsCache (schema bump 4→5), FeatureFlagsRepository con stale-while-revalidate (TTL 5min, errores silenciosos para servir cache stale), Riverpod featureFlagsProvider (vacío cuando no hay sesión) + helper isFeatureEnabled(ref, code).
Tabla Drift FeatureFlagsCache (code PK + enabled bool + fetchedAtUtc DateTime). Schema version bump 4→5 con migración createTable. Persistido para sobrevivir a kill de la app.
Riverpod featureFlagsProvider (FutureProvider<Map<String, bool>>): si no hay sesión devuelve mapa vacío (todos los flags caen al default opt-in false, consistente con el server); con sesión carga el cache + dispara refreshIfStale en background con unawaited. Re-evalúa automáticamente cuando cambia authStateProvider.
FeatureFlagsRepository (data/repositories/feature_flags_repository.dart): load() devuelve snapshot del cache (instantáneo, no bloquea UI); refresh() fuerza fetch + write atómico (transaction con delete + batch.insertAll); refreshIfStale() respeta TTL configurable (default 5min). Si el fetch falla, el cache stale sigue sirviendo — no propaga excepciones desde refreshIfStale (best-effort).
Helpers isFeatureEnabled(WidgetRef ref, String code) y isFeatureEnabledFromRef(Ref ref, String code) en core/feature_flags/feature_flag_helpers.dart. Semántica: estado de carga → false (default opt-in), estado de error → false, sin entrada en el mapa → false. La UI usa estos helpers para gatear features sin tocar el provider directo.
TODO: 'Flutter consumer de feature flags' cerrado en F8.32. Pendiente futuro: gateo de features concretas (UI WhatsApp en orders.share, etc. — bandera de smoke test queda como ejemplo para futuras fases).
8 tests Flutter (feature_flags_test.dart) con LocalDatabase.inMemory + _FakeFeatureFlagsApi: load vacío, refresh escribe atómico, refresh reemplaza snapshot (sin keys colgados), refreshIfStale fetch en empty, refreshIfStale skip en fresh, refreshIfStale re-fetch al expirar TTL, refreshIfStale swallows errors, refresh propaga errores cuando se llama explícitamente.
DTO EvaluatedFeatureFlagsResponse en Shared.Contracts.FeatureFlags. Inmutable, Dictionary<string, bool> + DateTimeOffset. La lógica de rollout/bucket queda centralizada server-side.
Endpoint GET /api/v1/feature-flags: requiere auth JWT, resuelve stableKey desde AppUser.ExternalId (preventistas ingestados del ERP) o cae al UserId.ToString("N") como fallback determinístico. Itera los flags definidos en BD del tenant y evalúa cada uno via IFeatureFlagService.IsEnabledAsync. Respuesta: { flags: { code: bool }, evaluatedAtUtc }.
4 tests backend (FeatureFlagsTests, total 16): cliente sin auth → 401; con auth y sin flags → mapa vacío; con auth y rollout 100 → flags evaluados ON/OFF correctos; rollout parcial sin ExternalId → evaluación determinística (mismo user, mismo bucket, mismas llamadas dan mismo resultado).
F8.31 — Feature flags por tenant (entidad + admin CRUD + UI)
30/05/2026 Cierra el compromiso firme post 1+2+3 (admin-users + orders/share + push backend). Entidad FeatureFlag por tenant (Code + Enabled + RolloutPercent + Description) + IFeatureFlagService con default opt-in (flag ausente = false) y cache 30s + IFeatureFlagAdminService cross-tenant + 5 endpoints admin con invalidación de cache scope-dance + UI MudBlazor en /feature-flags. Rollout gradual: bucket determinístico por FNV-1a (code+stableKey) repartiendo en [0,100); sin stableKey con RolloutPercent<100 → false.
IFeatureFlagAdminService cross-tenant (ITenantRegistry + ITenantDbContextFactory): List con filtros (query sobre Code/Description, enabled bool) + paginación; Get/Create/Update/Delete; código de error FeatureFlag.DuplicateCode (409), FeatureFlag.InvalidCode/InvalidRollout (400), FeatureFlag.NotFound (404).
Entidad FeatureFlag (por tenant): Code (unique, lowercase dotted convention) + Enabled + RolloutPercent (0-100 default 100) + Description + audit timestamps. Migración FeatureFlags con índice único en Code.
12 tests de integración (FeatureFlagsTests con IAsyncLifetime): create payload válido, duplicate 409, rollout inválido + code vacío 400, list con filtros (query+enabled), update enabled+rollout, update unknown 404, delete remueve, default opt-in false, full rollout true, disabled overrides rollout, partial rollout requiere stableKey (sin → false, con → determinístico), update invalida cache (toggle inmediato).
5 endpoints admin: GET /api/v1/admin/tenants/{tenantId}/feature-flags?q=&enabled=&offset=&limit=; GET /{flagId}; POST; PUT; DELETE. Invalidación de cache scope-dance (mismo patrón que /push/test): tras mutar, resuelve ITenantContext manualmente y llama IFeatureFlagService.InvalidateCache(code).
IFeatureFlagService runtime (tenant-scoped via TenantDbContext + ITenantContext): IsEnabledAsync(code, stableKey?) con default opt-in (flag ausente = false). Cache IMemoryCache con TTL 30s key=(tenantId, code) — toggles desde admin se propagan en ≤30s entre nodos sin invalidación, o instantáneo si el endpoint invalida explícito.
TODO: P1 'Feature flags' marcado como cerrado para este nivel de scope. Pendientes futuros anotados para fases siguientes: endpoint público GET /api/v1/feature-flags (consumer Flutter), targeting JSON (lista de userIds/roles), historial de cambios (audit log), expiry automático de flags vencidos.
Rollout gradual: cuando 0 < RolloutPercent < 100, IsEnabled requiere stableKey (típicamente AppUser.ExternalId). Bucket determinístico via FNV-1a 32-bit sobre (code|stableKey) — incluir code evita que un user 'afortunado' reciba todo o nada en flags distintos. Sin stableKey → false (no podemos repartir).
UI MudBlazor en /feature-flags (link en nav ya existía): tabla con Code/Estado/Rollout%/Actualizado/Acciones + filtros (tenant + buscar + activos) + diálogos Crear/Editar con MudSwitch + MudNumericField para rollout + descripción. Chip OFF/ON + chip warning cuando rollout<100.
F8.30 — Push notifications backend (FCM stub + trigger automático)
30/05/2026 Cierra el P1 más grande del backlog operativo: backend de push completo. Entidad DeviceToken por tenant + endpoints register/unregister + admin list + admin test push + IPushSender abstracto + IPushDispatchService + trigger automático en NotificationService cuando severity >= Critical (configurable). El sender es un stub que loguea hasta que F8.31 active Firebase real (runbook FIREBASE_PUSH_SETUP.md listo). Decisión: Firebase global no per-tenant (asimetría con SMTP — la app Flutter es una sola).
IPushSender abstracto + LoggingPushSender stub: si Push.Enabled=false → Success=false con código Push.Disabled; si FirebaseServiceAccountPath vacío → log WARN para que sea evidente que falta cablear pero devuelve Success=true para no quebrar el trigger fire-and-forget.
docs/FIREBASE_PUSH_SETUP.md: runbook completo para activar Firebase real (F8.31). Argumenta por qué un solo proyecto Firebase global (asimetría con SMTP per-tenant). 3 partes: setup Firebase + service account; agregar FirebaseAdmin NuGet + crear FcmPushSender + cambiar registro DI; integración Flutter (firebase_core + firebase_messaging + manifests + permisos Android 13+ y iOS). Cuestiones operativas: rotación de tokens, cleanup de tokens muertos, costo.
IPushDispatchService orquesta: filtra por Severity >= AutoPushMinSeverity (default Critical); resuelve tokens (dirigida por AppUser.ExternalId, broadcast = todos los del tenant); arma PushMessage con Title/Body/Category + Data dict con notificationId/relatedEntityType+Id para que el cliente sepa qué pantalla abrir; itera con try/catch per token.
IDeviceTokenService: RegisterAsync idempotente por Token (replay actualiza metadata + reasigna UserId si quedó pegado a un user anterior por reinstall sin clean logout); UnregisterByIdAsync con check ownership; UnregisterAllForUserAsync via ExecuteDeleteAsync (no materializa filas).
PushOptions en DeamPreventaOptions: Enabled (default true), AutoPushMinSeverity (default Critical), FirebaseServiceAccountPath (default vacío = stub mode). Configurable via DeamPreventa:Push:*.
TODO: P1 'Push notifications reales' baja a [~] con backend cerrado y F8.31 explícita. Pendientes futuros anotados: job cleanup de tokens > 30 días, opt-out per-tenant, lazy-loading del SDK Firebase.
Trigger automático en NotificationService.CreateOrderRejectedAsync: post-save dispatch via IPushDispatchService con try/catch silencioso. Si push falla, log WARN pero NO propaga — Notification persistida sigue siendo source of truth (sync down es plan B).
9 tests de integración (PushDevicesTests con IAsyncLifetime + FakePushSender en DI): register con metadata, register replay idempotente, register platform inválido → 400, unregister + re-delete 404, trigger automático con 2 devices capturando Title+Body+Category+Data, filtro de severity (Info < Critical → 0 captures), admin test push válido, admin test push 404 token inexistente, admin list con filtros.
Entidad DeviceToken (por tenant) con FK a AppUser cascade delete, Token unique, Platform enum (Android/Ios/Web), DeviceId del Flutter en secure_storage, DeviceName + AppVersion + LastSeenAtUtc para tracking. Migración DeviceTokens con 3 índices (Token unique, DeviceId, UserId) + FK ON DELETE CASCADE.
Endpoints: POST /api/v1/devices/register (idempotente); DELETE /api/v1/devices/{tokenId}; GET /api/v1/admin/tenants/{id}/devices con filtros (userId, platform) + paginación; POST /api/v1/admin/push/test (resuelve tenant context manualmente — los endpoints admin van con JWT cross-tenant).
F8.29 — POST /api/v1/orders/{id}/share (PDF + email reales)
29/05/2026 Cierra el P1 del backend para el reenvío de pedidos. PDF (QuestPDF Community) y email (MailKit + SMTP per-tenant) reales; WhatsApp/SMS como stubs documentados (501 + link al runbook Twilio). Decisión de no acoplar a vendor: SMTP es universal (SendGrid, Mailgun, AWS SES, smtp4dev local). Password SMTP cifrada at-rest con Data Protection (purpose dedicado SmtpSecretProtector).
docs/SMTP_SETUP.md: runbook completo con tabla comparativa de proveedores (SendGrid/Mailgun/Postmark/AWS SES/smtp4dev/Mailtrap/Resend), credenciales paso a paso, configuración en panel, validación, troubleshooting.
docs/TWILIO_SETUP.md: runbook para activar WhatsApp/SMS cuando llegue cliente. Dos partes (admin: setup Twilio + templates Meta; dev: SDK + TwilioSecretProtector con purpose dedicado + servicios espejos del SMTP). Estimación: ~3hs SMS, ~1 día WhatsApp por aprobación regulatoria.
IOrderPdfService con QuestPDF 2025.1.0 (license Community MIT) — layout A4 con header + bloque cliente + tabla líneas + totales + observaciones + footer. Una sola fuente de verdad del layout (no se duplica en Dart).
ITenantSmtpConfigService + ISmtpSecretProtector (purpose DEAM.Preventa.SmtpPassword.v1, análogo a ErpSecretProtector con purpose aislado para no chocar rotaciones de keys).
Bump de MailKit 4.8.0 → 4.17.0 y MimeKit 4.8.0 → 4.17.0 — versiones anteriores estaban afectadas por GHSA-9j88-vvj5-vhgr y GHSA-g7hc-96xr-gvvx respectivamente. NuGet auditor estaba bloqueando build (errors NU1902).
TODO: 2 P1 cerrados parcialmente (F4 /orders/{id}/share backend + F6 reenvío). UI Flutter en step 3 del pedido queda como follow-up (botón + share-sheet nativo del OS para PDF/email/WhatsApp app).
Settings.razor: nueva sección SMTP con SetupDocLink → docs/SMTP_SETUP.md. Password con semántica null=mantener, vacío=borrar, valor=setear (cifrado con Data Protection antes de persistir). DTO devuelve SmtpHasPassword: bool sin exponer el secret.
IEmailSender + SmtpEmailSender con MailKit 4.17 — vendor-agnóstico (funciona con SendGrid, Mailgun, Postmark, AWS SES, smtp4dev local, Mailtrap). SecureSocketOptions autodetecta: 465→SslOnConnect, 587→StartTls.
Password SMTP cifrada at-rest con ASP.NET Core Data Protection (purpose DEAM.Preventa.SmtpPassword.v1). Pérdida del DataProtectionKeysPath invalida todos los SMTP secrets — mismo riesgo operativo que ErpApiKey desde F8.6, ya documentado.
7 tests de integración (OrderShareTests con IAsyncLifetime + FakeEmailSender en DI vía TestWebAppFactory): PDF magic number + bytes válidos, 404 order inexistente, 412 sin SMTP, 200 email con FakeEmailSender capturando smtp descifrado + adjunto, 400 sin to, 501 whatsapp/sms con link runbook, 400 channel inválido.
POST /api/v1/orders/{orderId}/share con channel ∈ {pdf, email, whatsapp, sms}. PDF devuelve application/pdf; email envía via SMTP con PDF adjunto; whatsapp/sms → 501 Share.NotImplemented + link a docs/TWILIO_SETUP.md.
F8.28 — Página /admin-users (administración cross-tenant de admins del sistema)
29/05/2026 Cierra el P1 más bloqueante operativamente — hasta hoy los AdminUserRecord solo se podían sembrar en dev. En prod no había forma de gestionar admins sin acceder al servidor. Ahora cualquier SystemAdmin puede crear, editar, desactivar admins desde el panel. Guardrail crítico: no se puede desactivar el último SystemAdmin activo (409 Admin.LastSystemAdmin), evita quedarse fuera del panel.
Página Razor /admin-users con tabla filtrable + dialog crear (password opcional → genera random + modal one-time) + dialog editar (FullName + Roles, NO email) + acciones inline (Reset pwd, Desbloquear si lockout, Activar/Desactivar). Columna 'Seguridad' con chip rojo si lockout activo, ámbar con count si hay intentos fallidos, OK si limpio.
Guardrail crítico en SetActive: si target es SystemAdmin y es el último activo, devuelve 409 Admin.LastSystemAdmin. Evita el escenario en el que el operador se queda fuera del panel por error humano.
IAdminUserAdminService separado de IAdminUserService (que se queda con autenticación). Métodos: List + Get + Create + Update + SetActive + ResetPassword + Unlock. Trabaja directo sobre IDbContextFactory<SystemDbContext>, sin tenant context.
Nav link 'Admins' en MainLayout, entre Tenants y Usuarios, con ícono AdminPanelSettings.
ResetPasswordAsync limpia también FailedLoginAttempts + LockoutUntilUtc — la intención del operador al resetear contraseña incluye recuperar acceso. UnlockAsync por separado para cuando el admin se bloqueó accidentalmente y va a usar password vieja.
8 tests de integración (AdminUsersAdminTests con IAsyncLifetime): list inicial, create con password generado + replay 409, validaciones (sin roles / rol inválido / email malo) → 400, update FullName/Roles + no encontrado 404, guardrail último SystemAdmin → 409, reset password limpia lockout, unlock limpia lockout sin tocar password, endpoints sin auth → 401.
TODO: item P1 'Página /admin-users' cerrado. Recordatorio inline en el código: cuando se agreguen más roles (TenantAdmin/ReadOnlyAdmin), agregar al HashSet ValidRoles del servicio.
7 endpoints REST bajo /api/v1/admin/admins/*: GET (list), GET/{id}, POST, PUT/{id}, POST/{id}/deactivate, POST/{id}/activate, POST/{id}/reset-password, POST/{id}/unlock. Todos requieren rol SystemAdmin.
F8.27 — Componente SetupDocLink + docs Google Maps por tenant
28/05/2026 Pequeña fase de UX/docs antes de seguir con features grandes. Componente reutilizable que el admin ve inline cuando un campo de config requiere setup externo (API keys, tokens, credenciales). Patrón formalizado en TODO.md para que toda fase futura que agregue un campo con setup externo cree su .md correspondiente y lo cablee con SetupDocLink. Primera aplicación: /settings → Mapa, con nota condicional según el provider seleccionado (Google → GOOGLE_MAPS_SETUP, Mapbox → MAPBOX_SETUP).
TODO actualizado: regla nueva en 'Reglas para fases futuras' formaliza el patrón SetupDocLink — toda fase que agregue campo con setup externo debe crear docs/<SERVICE>_SETUP.md y cablear el componente. Feature flags (P1 original) anotado con urgencia condicional: diferido hasta cerrar admin-users + orders/share + push notifications. Hoy TenantConfig.Settings JSON cubre la diferenciación básica por tenant.
docs/GOOGLE_MAPS_SETUP.md: runbook completo en dos partes. Parte 1 (admin): crear proyecto GCP, habilitar Maps SDK, RESTRINGIR la API key (importante, sin restricción cualquiera puede usarla), configurarla en /settings, billing. Parte 2 (dev): gradle.properties.MAPS_API_KEY gitignored, verificar cableo del manifest desde F8.25, CI/CD con -PMAPS_API_KEY=... Tabla de troubleshooting al final.
Componente <SetupDocLink> reutilizable en Components/Shared/ — MudAlert.Outlined compacto con título + descripción + link icónico al .md del repo (target=_blank). Parámetros: Title, Description?, DocPath, Severity (Info/Warning). Carpeta nueva Components/Shared/ + import al _Imports.razor.
Settings.razor: después del MudSelect de Map provider, nota condicional con SetupDocLink. provider=google → Info link a GOOGLE_MAPS_SETUP.md; provider=mapbox → Warning (integración no real) link a MAPBOX_SETUP.md; provider=osm → nada (no requiere setup).
F8.26 — Mapbox stub documentado (decisión de diferir la integración real)
28/05/2026 Cierre conservador del segundo P0 de F7. Mapbox queda como stub documentado porque mapbox_maps_flutter requiere MAPBOX_DOWNLOADS_TOKEN para que Gradle pueda resolver el SDK; sin ese token cualquier build Android falla. Decidimos no imponer esa fricción a todo el equipo cuando ningún tenant concreto está usando Mapbox. El runbook completo de activación queda en docs/MAPBOX_SETUP.md para ejecutar en ~2 horas cuando aparezca el primer cliente.
MapboxMapAdapter ahora degrada explícitamente a OsmMapAdapter (mismo patrón que el fallback de Google cuando apiKey está vacía). Reemplaza el _StubProvider placeholder por un fallback funcional — si un tenant tiene mapProvider=mapbox sin que se haya hecho el setup nativo, ve OSM en lugar de un placeholder muerto.
docs/MAPBOX_SETUP.md: runbook paso a paso para activar Mapbox cuando aparezca el primer cliente. Incluye: conseguir los dos tokens distintos (downloads gratuito + access público por tenant), agregar paquete al pubspec, configurar build.gradle.kts con el repo Maven privado autenticado, esqueleto del adapter real con notas sobre PointAnnotationManager / PolylineAnnotationManager, pasos de QA en device, y follow-ups (lazy-loading, markers Material-styled, offline tile caching).
TODO actualizado: item P0 'Adapters Google + Mapbox reales' sigue como [~] (Google cerrado real, Mapbox cerrado como stub documentado con runbook). El item se cierra completo cuando se ejecute el runbook de Mapbox.
Eliminado _StubProvider de map_adapter.dart (era usado solo por Mapbox; el resto de paths ya degradaban a OSM tras F8.25).
F8.25 — Google Maps real + Geolocator centrar en mí
28/05/2026 Cierra los 2 P0 de F7: GoogleMapAdapter monta un GoogleMap widget real con fallback explícito a OSM si la apiKey del tenant viene vacía (evita la UX rota de mapa gris); LocationService envuelve geolocator con 5 outcomes distinguibles; botón centrar-en-mí en MapScreen y RoutePlanner con re-center via KeyedSubtree+tick. Mapbox queda como F8.26 espejando el patrón.
TODO: 2 P0 de F7 bajan a [~] (Google + Geolocator). Mapbox queda como F8.26 espejando el patrón; live tracking durante visitas queda anotado como follow-up del VisitEvent.
minSdk subido a max(flutter.minSdkVersion, 21) — google_maps_flutter lo requiere. Blindaje por si el floor de Flutter baja en algún update.
Fallback explícito Google → OSM cuando TenantConfig.map.apiKey está vacía o null, con debugPrint warning. Evita que devs/QA vean un mapa gris en builds sin la key inyectada en el manifest — ven OSM funcional.
2 widget tests nuevos en map_adapter_test.dart: GoogleMapAdapter con apiKey vacía o null degrada a un widget FlutterMap (verifica el fallback).
MapAdapter.build extendido con parameter userLocation: GeoPoint? — ambos adapters (OSM y Google) dibujan un _UserLocationDot (pin azul + anillo translúcido, estilo Material) en esa coord.
LocationService wrapper sobre geolocator: 5 outcomes (ok/serviceDisabled/permissionDenied/permissionDeniedForever/error), nunca tira excepciones, chequea isLocationServiceEnabled antes de pedir permisos. UI mapea cada outcome a un snackbar específico.
Botón centrar-en-mí (FloatingActionButton.small) en MapScreen y RoutePlanner con loading state. Re-center mechanics: KeyedSubtree con ValueKey que cambia al tap fuerza rebuild del adapter con initialCenter = userLocation (~30ms, sin superficie API imperativa).
GoogleMapAdapter real con google_maps_flutter 2.9.0 — Marker + Polyline + InfoWindow traducidos desde el contrato común MapMarker/GeoPoint. myLocationEnabled=false: el dot del usuario lo dibujamos nosotros para que sea consistente entre OSM y Google.
AndroidManifest.xml: permisos ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION + INTERNET, meta-data com.google.android.geo.API_KEY=${MAPS_API_KEY}. build.gradle.kts inyecta MAPS_API_KEY desde gradle.properties (gitignored) o -PMAPS_API_KEY=... Si no se setea, queda string vacío — el adapter degrada a OSM por TenantConfig.apiKey, así que la app sigue funcional.
F8.24 — VisitEvent sub-sistema (check-in / check-out / no-venta)
27/05/2026 Cierra parcialmente el P1 'VisitEvent full' de F4. El admin ya no ve sólo el estado final de una parada (Visited/Skipped); ve la secuencia real de eventos: check-in al llegar, check-out al irse con duración, motivos de no-venta — todos con timestamp y geo. Backend + admin page + Flutter inline emission; geo-pings background y offline queue quedan para una fase futura.
Entidad VisitEvent (Id + ClientLocalId unique + Type ∈ {check_in,check_out,no_sale_reason,geo_ping} + RouteStopId? + PreventistaExternalId + lat/lng + OccurredAtUtc + DurationSeconds? + Reason? + Notes?). Modelo histórico inmutable.
Flutter route_planner_screen: botón Visitar emite check_in inline con lat/lng de la parada; botón Saltar emite no_sale_reason con el motivo recolectado. Best-effort (try/catch silencioso) — si falla, no bloquea el optimistic update local.
TODO: VisitEvent full baja a [~] con follow-ups anotados (geo-pings background + flutter_background_service + permisos Android/iOS, offline queue Drift, persistir reorder manual del preventista).
6 tests de integración (VisitEventsTests con IAsyncLifetime): 422 sin ExternalId, batch válido, idempotencia, type inválido + batch mixto, admin GET filtrado, 404 tenant inexistente.
POST /api/v1/visits/events: batch idempotente por ClientLocalId, validación per-event (type inválido va a Errors pero no rompe el resto del batch), de-dup intra-batch. 422 si el usuario no tiene ExternalId asignado.
Migración VisitEvents (tabla visit_events + 5 índices: ClientLocalId unique, PreventistaExternalId, RouteStopId, OccurredAtUtc, ChangedAtTicks).
Admin GET /api/v1/admin/tenants/{id}/visits con filtros (preventista, type, rango temporal). Página /visits con tabla cronológica + chips de tipo + columna lat/lng + formato de duración human-readable. Link en el nav entre Pedidos y Outbox.
F8.23 — Rate limiting por API key sobre /erp/v1/*
27/05/2026 Cierra el último P1 del rate-limiting: admin login + tenant login + ERP ingest ahora tienen tope. Partition por X-Api-Key (cada cliente su bucket) con fallback a IP para floods anónimos. Cambio de pipeline: UseRateLimiter() corre antes de UseAuthentication() para que la protección aplique incluso ante requests sin credenciales.
3 tests de integración (ErpRateLimitTests): 101 reqs mismo key → 429; 60 reqs por 2 keys distintos sin interferencia; 101 reqs anónimas → 429 vía partition IP.
Policy erp-ingest (FixedWindow): partition por X-Api-Key si presente, fallback ip:<remoteAddr> para tráfico anónimo. Default PermitLimit=600 req/min (10 req/s sustained).
UseRateLimiter() reordenado ANTES de UseAuthentication() en el pipeline. Sin esto, requests anónimos hacia /erp/v1/* eran rechazados por el HMAC handler (401) ANTES de que el rate limit los viera, dejando un vector de DB-lookup unbounded ante keys inválidas.
TODO: 1 P1 cerrado (rate limiting por API key) + 6 items movidos a ## Descartado (2FA admin, refresh tokens admin, idempotency_keys cleanup, apiBaseUrl --dart-define, X-Device-Id, GUID real).
ErpRateLimitOptions (PermitLimit + WindowSeconds) configurables via DeamPreventa:ErpRateLimit:*.
F8.22 — Backups automáticos de .db con rotación
27/05/2026 Cierra el P1 más crítico para prod readiness: hasta hoy no había backups automáticos, una corrupción del archivo SQLite o un rm accidental era pérdida total. BackupJob nightly usa SQLite Online Backup API (no corrompe BDs activas), atomic via temp+rename, rotación por nombre lexicográfico de timestamps ISO.
6 tests de integración en BackupServiceTests con IAsyncLifetime: snapshot para system+demo, archivo es SQLite válido (verificamos abriéndolo y consultando la tabla tenants), 2 corridas → 2 archivos distintos, rotación borra los más viejos preservando los 3 más recientes.
SqliteConnection.ClearAllPools() antes del rename para liberar handles pooled que podrían lockear el .tmp.
BackupService: itera system DB + tenants y dispara SqliteConnection.BackupDatabase (Online Backup API) — copia BDs activas sin riesgo de corrupción. Source en Mode=ReadOnly, destination en archivo .tmp con Mode=ReadWriteCreate, rename atómico al final.
BackupOptions: Enabled=true, IntervalHours=24, RetainCount=14, BackupsRoot=data/_backups. Layout: <Root>/system/{ts}.db + <Root>/tenants/<slug>/{ts}.db.
TODO: P1 de F2 'Backups automáticos' cerrado. Pendiente operativo anotado: en cloud el BackupsRoot debería vivir en un volumen separado de las BDs (bucket con versioning) para que un fallo de disco no pierda backups + datos.
BackupJob (Quartz, [DisallowConcurrentExecution], default cada 24 hs). Si una BD falla, las demás siguen — errores se loguean ERROR. Trigger arranca 7 min después del bootstrap para no chocar con initial sync.
Rotación por nombre lexicográfico: archivos timestamped yyyy-MM-ddTHH-mm-ssZ.db, conserva los RetainCount más recientes (default 14) y borra los demás.
F8.21 — Serilog sink → system_log + retention jobs
26/05/2026 Cierra 2 P1 de F2 que viven desde el arranque: el Sink Serilog → system_log (la página /logs deja de ser cosmética — ahora captura WARN+ de cualquier subsistema automáticamente) y la política de retención configurable (audit_log + system_log) con un Quartz job que purga nightly.
TODO: 2 P1 cerrados en F2 (Sink Serilog → system_log y política de retención).
6 tests de integración en SystemLogSinkAndRetentionTests con IAsyncLifetime: sink WARN/INFO/Error, retention system_log + audit_log per tenant, skip-when-days-is-zero.
Sink síncrono con try/swallow defensivo — failures nunca propagan a la pipeline. Console + File siguen como redundancia.
DI explícito de OutboxRecoveryJob + RetentionPurgeJob como transient services para que tests puedan resolverlos y ejecutar Execute() sin pasar por el scheduler.
RetentionPurgeJob usa raw SQL DELETE con cutoff ISO 8601 — SQLite EF Core no traduce comparaciones de DateTimeOffset a LINQ. Como todos los timestamps son UTC, la comparación de strings ISO 8601 es cronológica y aprovecha el índice existente en TimestampUtc.
SystemLogSerilogSink: ILogEventSink que filtra por nivel mínimo (default Warning) y persiste a system_log via IDbContextFactory<SystemDbContext>. Mapea SourceContext → Category, TenantId del enricher → TenantId, exception → message + stack trace en Properties JSON.
RetentionPurgeJob (Quartz, default cada 24 hs): purga system_log y audit_log de cada tenant con cutoff = UtcNow - retentionDays. AuditLogDays=0 o SystemLogDays=0 deshabilita el purge del recurso.
UseSerilog con preserveStaticLogger=true para aislar el sink por host — crítico para tests paralelos (sin esto, el último TestWebAppFactory buildeado le robaba el sink a los anteriores).
RetentionOptions defaults: AuditLogDays=180, SystemLogDays=60, IntervalHours=24. SystemLogSinkOptions: Enabled=true, MinimumLevel=Warning.
F8.20 — Fuentes Flutter embebidas + wire pushUp en Home
26/05/2026 Cierra los 2 P0 más viejos del backlog Flutter (arrastraban de F0/F5/F6/F7). Variable TTFs de Inter + JetBrains Mono embebidas (~1 MB bundle impact) para que la app respete el diseño desde el primer launch sin caer a sans-serif del sistema. Sync del Home ahora incluye pushUp además de pullDown para que los pedidos confirmados offline lleguen al ERP sin que el preventista abra /sync.
pubspec.yaml registra las familias Inter y JetBrainsMono. AppFonts.sans/mono ya apuntaban a esos nombres desde F0.
Variable TTFs Inter + JetBrains Mono en flutter_app/assets/fonts/. Un solo archivo por familia (eje wght) cubre todos los pesos — Flutter interpola en runtime.
home_screen.dart._runSync() ahora hace pullDownAll + pushUp (mismo orden que /sync). Summary incluye 'pedidos: X ok[, Y con error]'. El método pushUp ya existía desde F5/F6 — sólo faltaba cablearlo en el botón del Home.
TODO: 4 P0s cerrados (3 entradas de fuentes en F0/F5/F7 + sync up en F5).
F8.19 — Hardening de /auth/login + logout-all-devices
26/05/2026 Cierra 3 items de F1 que vivían desde el arranque: rate limit por IP en el login del tenant, lockout real de Identity (5 intentos → 10 min con 423/Retry-After), y endpoint POST /auth/logout-all-devices. Bug fix incluido: LockoutEnabled=true en todos los paths de creación de AppUser (provisioner + ingest + admin) — antes era false por default y bypassaba el lockout entero.
7 tests de integración (TenantAuthHardeningTests): lockout activa tras 5 fallos, bloquea incluso con password correcto, login exitoso resetea contador, usuario inexistente nunca 423, logout-all-devices revoca tokens, requiere auth, devuelve 0 si no hay tokens activos.
Error record extendido con LockedUntilUtc init-only. AuthErrors.LockedOut(until) factory. ToResponse mapea Auth.LockedOut a 423 + Retry-After computado.
Lockout de Identity activo: 5 intentos fallidos consecutivos → 10 min de bloqueo. Endpoint devuelve 423 con header Retry-After + body AuthLockedOutDto. Usuario inexistente sigue devolviendo 401 (no revela existencia).
LockoutEnabled=true en TenantProvisioner, TenantUserAdminService y UserIngestStrategy. Antes era false por default (Identity hace early-return en false), por lo que el lockout configurado en DI estaba inactivo.
Rate limiting en /api/v1/auth/login: FixedWindow por IP (60 req/min). Espejo del policy admin-login de F8.7 con bucket independiente.
TODO: 3 items P1/P2 cerrados en F1 (rate limit, logout-all-devices, lockout surfacing 423).
POST /api/v1/auth/logout-all-devices (autenticado): revoca todos los refresh tokens activos del usuario y devuelve { RevokedCount }. Útil cuando se sospecha compromiso de cuenta.
F8.18 — Audit drill-down (before/after JSON modal)
26/05/2026 Cierra el P1 marcado [~] desde F2/F8: la página /audit ahora soporta click-to-drill-down con un modal que pinta el JSON before/after pretty-printed. El interceptor ya guardaba los campos; recién ahora se exponen al admin.
Endpoint GET /api/v1/admin/tenants/{id}/audit/{entryId:long} bajo policy SystemAdmin; 404 para tenant/entry desconocidos.
4 tests de integración (AdminAuditDetailTests): detalle devuelve After con DeviceId tras login, 404 para entry inexistente, 404 para tenant desconocido, 401 sin JWT admin.
AuditLogEntryDetailDto incluye Before y After JSON. ITenantAdminService.GetAuditEntryAsync devuelve el detalle on-demand (no en la lista paginada, por tamaño).
TODOs cerrados: F2 'Página admin para audit_log' pasa de [~] a [x]; F8 'Drill-down al diff before/after' cerrado.
Audit.razor: rows clickeables abren modal con metadata + dos columnas Before/After. Pretty-print con JsonSerializer (WriteIndented), fallback al raw si no parsea. Placeholders explícitos para inserts (sin Before) y deletes (sin After).
F8.17 — Acciones de mutación en /outbox (retry + delete)
25/05/2026 Cierra un P1 que arrastraba desde F4: ahora desde el panel admin se puede reintentar un outbox en DeadLetter (vuelve a Pending y revive el Order si fue rechazado por dead-letter) o eliminarlo manualmente. El Order queda intacto para audit. Sólo aplica a DeadLetter — Pending/InTransit/Acked/Rejected están protegidos contra mutación.
ITenantAdminService.DeleteOutboxAsync: borra fila del outbox (sólo DeadLetter). Order queda intacto para preservar audit y FKs.
TODO: P1 'Acciones de mutación en /outbox' cerrado. F8.11 actualiza el sub-pendiente 'panel admin con vista de DeadLetter' a cerrado.
9 tests de integración en AdminOutboxActionsTests con IAsyncLifetime: happy path retry/delete, motivo de rejection no se pisa si no fue dead-letter, 409 para estados protegidos, 404s, auth required.
ITenantAdminService.RetryOutboxAsync: DeadLetter → Pending, attempts=0, limpia first/last attempt. Si Order quedó Rejected por dead-letter, lo revive a Confirmed; si fue rechazado por otro motivo, no se toca.
Outbox.razor: columna Acciones nueva con botones Reintentar/Eliminar para filas DeadLetter. Delete confirma con IDialogService.ShowMessageBox; _actionInFlight evita doble-click.
Endpoints POST /api/v1/admin/tenants/{id}/outbox/{outboxId}/retry y DELETE .../outbox/{outboxId}. 200/204 OK, 404 not found, 409 Outbox.InvalidState para estados protegidos.
F8.16 — Páginas admin /users + /settings (CRUD)
25/05/2026 Cierra 2 de las 4 páginas pendientes de F8.15: /users con CRUD completo (list/create/activate/deactivate/reset-password) y /settings con form de map/locale/branding validado. Quedan /feature-flags y acciones de mutación en /outbox.
ITenantSettingsAdminService con validación: MapProvider ∈ {osm,google,mapbox}, lat/lng/zoom dentro de rango, Currency ISO 3 mayúsculas, colores hex #RRGGBB. Invalida el cache del registry al guardar.
Tenants.razor suma deep-links a /users y /settings con ?tenantId= pre-cargado.
Settings.razor: form completo en dos columnas (mapa / localización + branding) con preview en vivo de hex colors. Botones Guardar/Descartar.
TODO: item P1 'Página /users' cerrado en F8. F8.15 marca /settings como cerrado en su sub-item. Quedan /feature-flags y acciones de mutación en /outbox. Nuevo P2: AdminAuditLog cross-tenant cuando exista la tabla system_audit_log.
Create con password vacío genera random (24 bytes base64-url) y marca MustChangePassword=true. Reset-password y deactivate revocan todos los refresh tokens activos del usuario.
Endpoints GET/POST/PUT bajo /api/v1/admin/tenants/{id}/users[/{uid}/(deactivate|activate|reset-password)] y /settings. AdminUserCommandResult<T> tipado distingue Tenant.NotFound, User.EmailTaken y validation.
Tests de integración: users (lista, filtros, create con/sin password, login con must_change_password, deactivate revoca refresh tokens, reactivate restaura, reset-password) + settings (defaults, persistencia, validación de provider/currency/color, 404s).
ITenantUserAdminService: CRUD de AppUser por tenant sin requerir ITenantContext resuelto (escribe directo contra TenantDbContext con PasswordHasher<AppUser>, normalización y SecurityStamp fresh). Replica las invariantes de Identity.
Users.razor: tabla con filtros + diálogo de creación + modal de password 'una sola vez' para create-generado y reset-password. Acciones inline.
F8.15 — Páginas admin solo-lectura (Orders, Outbox, Sync)
25/05/2026 Cierra parcialmente el TODO P1 'Páginas pendientes en el nav': 3 de 6 entregadas (Orders con detalle modal de líneas + outbox, Outbox con counters y resaltado de DeadLetter, Sync overview con watermarks por recurso). Diferidas /users, /feature-flags, /settings y acciones de mutación en outbox.
Sync.razor: cards de resumen (total recursos + idempotency keys activas) + tabla por recurso con TotalAccepted/Failed/LastIngestedAtUtc/LastCursor.
DTOs en Shared.Contracts/Admin: OrderSummaryDto/OrderLineDto/OrderDetailDto/OrderPageDto, OutboxRowDto/OutboxPageDto/OutboxCountersDto, SyncWatermarkDto/SyncOverviewDto.
Endpoints GET /api/v1/admin/tenants/{id}/orders[/{orderId}], /outbox, /sync — todos bajo policy SystemAdmin, devuelven 404 para tenant inexistente.
TODOs nuevos: acciones retry/delete en /outbox, /feature-flags desde cero, /settings con PUT auditado. /users sigue cubierto por item P1 existente.
Outbox.razor: counters por estado en el header (Pending/InTransit/Acked/Rejected/DeadLetter), fila de DeadLetter resaltada con fondo rojo claro, truncado de LastError.
ITenantAdminService extendido con GetOrdersAsync/GetOrderDetailAsync/GetOutboxAsync/GetSyncOverviewAsync. Enriquecimiento con LineCount via join agrupado y conteo de outbox por estado.
12 tests de integración en AdminOrdersOutboxSyncTests.cs con IAsyncLifetime (factory fresca por test): cobertura de paginación, filtros, 404s, auth required, counters y watermarks.
Orders.razor: tabla con filtros (status + clientCode), click en fila abre modal con detalle de líneas, totales, observaciones, dirección, payment method y bloque de outbox asociado.
Tenants.razor: 3 botones nuevos por fila (Pedidos/Outbox/Sync) con ?tenantId= pre-cargado para navegación directa.
F8.14 — SystemAdmin password hardening + self-service password change
24/05/2026 Dos P0 cerrados. Etapa 1: appsettings.json sin passwords en plain text; auto-generación con WARN en Development; CLI seed-system-admin para Production. Etapa 2: POST /api/v1/auth/change-password con flag MustChangePassword + JWT claim + UI Flutter con router gate.
AppUser.MustChangePassword + migración UserMustChangePassword. UserIngestStrategy lo setea cuando crea un user con password generado random.
POST /api/v1/auth/change-password (autenticado): valida current, aplica Identity policy, limpia MustChangePassword, revoca refresh tokens activos y emite par nuevo. 4 tests cubren happy/wrong-current/weak/ingest-flow.
CLI tools/DEAM.Preventa.Migrations seed-system-admin --email X --password Y para sembrar/asegurar el SystemAdmin en Production sin tocar appsettings.json. Idempotente.
Flutter ChangePasswordScreen + ruta /change-password + AuthStateController.changePassword. Router gate fuerza la pantalla mientras mustChangePassword esté activo.
DemoSeedOptions defaults vacíos para AdminPassword y SystemAdminPassword. Si están vacíos en arranque y Environment=Development, se generan random (24 bytes base64-url) y se loguean WARN visibles en consola.
appsettings.json depurado: passwords + SigningKey vacíos (placeholders + comentarios _xxx_comment con instrucciones). appsettings.Development.json mantiene los valores DX.
JWT incluye claim must_change_password=true cuando aplica. AuthenticatedUserDto lo expone; LoginCommand y RefreshTokenCommand lo propagan.
F8.13 — Users desde el ERP (catálogo ERP completo)
24/05/2026 Ingesta de preventistas via ERP cierra el catálogo de 13 recursos. Cuenta con upsert por ExternalId, sync de roles, password opcional con generación random + WARN, y tombstone que desactiva + revoca refresh tokens activos.
UserIngestDto + UserIngestStrategy + POST /erp/v1/ingest/users. Clave de negocio: ExternalId; Email es UserName y puede cambiar entre ingests sin colisionar.
Tombstone marca IsActive=false + revoca TODOS los refresh tokens activos del usuario (mata sus sesiones al próximo refresh). El user no se borra para preservar audit log y FKs.
Validación: email format (regex), externalId/email/fullName obligatorios excepto en tombstone, roles no vacíos. Email duplicado contra OTRO ExternalId falla a nivel ítem (Ingest.ItemFailed).
El catálogo ERP queda completo: Client, Product, Category, PriceList, Price (F3) + Stock, Taxes, PaymentMethods, DiscountRules (F8.8) + Routes, RouteStops, PromoBanners, Notifications (F8.12) + Users (F8.13) = 13 recursos.
TODOs nuevos: VisitEvent full (sub-sistema P1 con check-in/check-out/no-sale-reason + geo-pings y persistencia de reorder), push real FCM+APNs (P1, complementa F8.12 que solo agrega Notification al sync down).
Password: opcional en create. Si no viene, se genera random (24 bytes base64-url) y se loguea WARN — visible en Development; en Production el ERP debe enviarlo o se implementa flujo de invitación (TODO P1). Update NUNCA toca el password (es self-service).
Update con IsActive=false (sin tombstone) también revoca tokens — mismo comportamiento que el tombstone explícito.
Sync de roles: el strategy hace set algebra contra la tabla roles del tenant (agrega faltantes, quita orphans). Defaults a ['Preventista'] si no se especifican.
F8.12 — Catálogo F3.5 parte 2: Routes + PromoBanners + Notifications
24/05/2026 Tres recursos nuevos del catálogo ERP entregados end-to-end (backend + sync + UI Flutter): ruta del día con acciones de visita, banners promocionales en home, inbox de notificaciones con auto-creación al dead-letter.
Route + RouteStop (FK + cascade) con ingest /erp/v1/ingest/routes. Stops embebidos; el upsert reemplaza el set con ExecuteDelete + INSERT (evita colisión SQLite delete+insert sobre unique key).
GET /api/v1/route/today resuelve la ruta por (PreventistaExternalId, día UTC del cliente) leyendo el AppUser.ExternalId del JWT sub. Devuelve null si el usuario no tiene ExternalId (creado por provisioning).
Migración tenant CatalogF35Part2 con 4 tablas (routes, route_stops, promo_banners, notifications) + 8 índices (unique por clave de negocio + ChangedAtTicks). Drift inMemory test agrega un pump final para drenar timers de StreamQueryStore.
Sync down extendido con 4 módulos nuevos (Routes, RouteStops, PromoBanners, Notifications); cliente Flutter persiste en Drift v3→v4 con onUpgrade que crea las tablas en BDs existentes.
POST /api/v1/route/stops/{id}/visit y /skip cambian el estado de la parada + setean VisitedAtUtc / SkipReason. Skip requiere reason (400 si vacío).
Notification (broadcast o dirigida a TargetUserExternalId) con severidad + categoría + RelatedEntity + ingest /erp/v1/ingest/notifications + GET /api/v1/notifications (filtra por usuario, ordenado por dispatched desc) + POST /notifications/{id}/read.
Flutter: carrusel de PromoBanners en home (filtra por validFrom/To del día actual), badge de notificaciones no leídas en AppBar con navegación a la nueva NotificationsScreen, indicador 'en ruta hoy' en cada card del CRM.
OutboxRecoveryService crea automáticamente una Notification 'order.rejected' Critical cuando un pedido cae en DeadLetter (idempotente por orderId). Cierra el loop F8.11: el preventista ve el rechazo sin tener que abrir el panel admin.
PromoBanner con severity Info/Success/Warning/Highlight + ventana ValidFrom/To + ingest /erp/v1/ingest/promo-banners.
Flutter: RoutePlanner real (reemplaza el stub que usaba clientes geolocalizados) con polyline, contadores Pendiente/Visitado/Salteado y acciones por parada. Saltar abre bottom sheet pidiendo motivo.
F8.11 — Reintentos del outbox + dead-letter
24/05/2026 Quartz job nocturno (default cada 5 min) revuelve filas OutboxOrder zombies (InTransit sin ack/reject) a Pending; las que superan MaxAttempts pasan a DeadLetter con el Order asociado marcado Rejected.
OutboxRecoveryJob (Quartz, [DisallowConcurrentExecution]): itera todos los tenants y crea scope DI con ITenantContext seteado por tenant; continúa ante fallo de uno individual.
Quartz scheduler cableado por primera vez en DependencyInjection: AddQuartz + AddQuartzHostedService(WaitForJobsToComplete).
Cuando un outbox cae en DeadLetter, su Order asociado pasa a Rejected con motivo 'Dead-lettered por recovery: ...' — el preventista lo ve via sync down sin tener que revisar el admin.
OutboxRecoveryOptions (Enabled, IntervalSeconds=300, StaleAfterMinutes=30, MaxAttempts=10, BatchSize=500) — todos overrideables desde appsettings.
IOutboxRecoveryService: por tenant, mueve InTransit con LastAttemptAtUtc < UtcNow − StaleAfterMinutes a Pending; promueve a DeadLetter si Attempts >= MaxAttempts.
F8.10 — UI consumer Flutter + cálculo de impuestos backend
22/05/2026 El catálogo F3.5 ya es visible y usable: badge de stock en cards/rows, picker de payment method y chips dinámicos de discount rules en el step 3 del pedido, preview de impuestos en cliente y cálculo real en backend.
Order.PaymentMethodCode + validación en handler (404 si el código no existe o está inactivo). DispatchOrderDto y OrderResponse lo exponen.
Step 3 del pedido: picker de payment method (chips dinámicos), chips de discount rules (Type=percent) con fallback a 0/3/5/10%, preview de impuestos en TotalsCard (subtotal × sumRate, recalculado por el backend al hacer push).
Flutter: CatalogF35Repository con queries para stock-por-sku (sumado entre warehouses), payment methods activos, discount rules percent, taxes activos + sumActiveTaxRates.
StockBadge widget (verde/ámbar/rojo) integrado en CatalogScreen (grid+list) y en step 2 del pedido (deshabilita 'Agregar' si SKU sin stock).
OrderDraft expone taxRateSum + taxableBase + taxTotal; setPaymentMethodCode y setTaxRateSum mantienen el estado del step 3.
Drift v2 → v3: orders.paymentMethodCode persistido localmente y enviado al backend al hacer push.
CreateOrderCommandHandler aplica TaxTotal = (Subtotal − Discount) × sum(rates de taxes activos); Total = base + TaxTotal.
F8.9 — Sync down de Stock, Taxes, PaymentMethods, DiscountRules
22/05/2026 Las 4 entidades sumadas en F8.8 ahora viajan al cliente Flutter vía /api/v1/sync/down con cursor propio por módulo. Drift agrega las tablas locales (migración v1→v2) y SyncEngine las persiste con tombstones.
DTOs StockSyncDto, TaxSyncDto, PaymentMethodSyncDto, DiscountRuleSyncDto + 4 cursores nuevos en SyncCursors.
SyncDownService.ExecuteAsync emite las 4 listas con paginación y cursor por ChangedAtTicks; HasMore considera los 10 módulos.
Drift schemaVersion 1→2 con onUpgrade que crea Stocks/Taxes/PaymentMethods/DiscountRules en la BD local del cliente.
TODO actualizado: queda el UI consumer (badge stock en catálogo, pickers de payment method y descuento, cálculo de impuestos usando la tabla local).
Pantalla Sync del Flutter ahora lista los 10 módulos (Stock, Impuestos, Medios de pago, Descuentos).
SyncEngine.pullDown persiste las 4 listas en transacción con tombstones (delete on isDeleted) y actualiza los cursores correspondientes.
F8.8 — Catálogo ERP F3.5 (parte 1)
22/05/2026 Cuatro recursos nuevos de ingesta desde el ERP: Stock, Taxes, PaymentMethods, DiscountRules. Desbloquean F4 (cálculo de impuestos) y F6 (badge de stock + picker de medios de pago).
DiscountRules (Code, Name, Type 'percent'/'amount', Value, MinQuantity opcional). POST /erp/v1/ingest/discount-rules con validación de tipo y rango.
Stock (WarehouseCode + Sku) con Quantity + ReservedQuantity. POST /erp/v1/ingest/stock.
TODO actualizado: queda F3.5 parte 2 (Routes/RouteStops, PromoBanners, Notifications, Users) y el cableado del sync down a Flutter.
Migración CatalogF35 crea las 4 tablas + índices únicos por clave de negocio + índices sobre ChangedAtTicks.
Taxes (Code, Name, Rate decimal en [0,1], IsActive). POST /erp/v1/ingest/taxes con validación de rango.
PaymentMethods (Code, Name, SortOrder, IsActive). POST /erp/v1/ingest/payment-methods.
F8.7 — Rate limiting + lockout en admin login
22/05/2026 POST /api/v1/admin/auth/login ahora tiene defensa anti-bruteforce: lockout de 10min tras 5 intentos fallidos consecutivos por cuenta + rate limit por IP (60 req/min).
Rate limit por IP en /api/v1/admin/auth/login (FixedWindowLimiter, 60 req/min). Excedido → 429 con Retry-After.
AdminLoginOutcome distingue Success / LockedOut / InvalidCredentials. Usuario inexistente devuelve 401 (no revela existencia).
AdminLockedOutDto + header Retry-After en respuesta 423. El Login.razor del Admin muestra 'Reintentá en ~N min' cuando aplica.
Migración AdminLockout agrega las dos columnas nuevas a admin_users.
Lockout por cuenta: FailedLoginAttempts + LockoutUntilUtc en AdminUserRecord. Tras 5 fallos consecutivos, 423 Locked durante 10min.
F8.6 — HmacSecret cifrado at-rest
22/05/2026 Los secrets HMAC de los API keys ERP ahora se persisten cifrados con ASP.NET Core Data Protection. Acceso al archivo deam.system.db ya no expone los secrets en claro.
Data Protection registrado con SetApplicationName + PersistKeysToFileSystem(DataProtectionKeysPath); default data/dp-keys configurable.
Migración EncryptHmacSecret amplía MaxLength a 1024 chars (no-op en SQLite por type affinity TEXT).
IErpSecretProtector encapsula Protect/Unprotect; legacy plain-text se detecta por ausencia de prefijo y se upgradea lazy en el primer FindActiveAsync.
ErpApiKey.HmacSecret se persiste cifrado con prefijo enc.v1: vía IDataProtector (purpose DEAM.Preventa.ErpApiKey.v1).
F8.5 — Migraciones EF Core formales
22/05/2026 Reemplazo de Database.EnsureCreated() por migraciones EF Core reales. Cambios futuros del esquema se aplican vía MigrateAsync sin romper BDs existentes.
SystemDbInitializerHostedService y TenantProvisioner ahora aplican migraciones vía MigrateAsync() en lugar de EnsureCreatedAsync().
CLI tools/DEAM.Preventa.Migrations: migrate-system / migrate-tenants / migrate-tenant ahora reportan migraciones pendientes y continúan ante fallo de un tenant individual (suma fallos al final).
IDesignTimeDbContextFactory<SystemDbContext> e IDesignTimeDbContextFactory<TenantDbContext> habilitan dotnet ef migrations add / database update.
Migración Initial generada para System (6 tablas) y Tenant (Identity + refresh_tokens + audit_log + catálogo + idempotency + watermarks + orders + outbox).
F8 — Admin auth + paneles que faltan
20/05/2026 Auth real del Admin (cookie + JWT cross-tenant), endpoints /api/v1/admin/* protegidos por SystemAdmin, paneles Tenants / API keys ERP / Audit log.
AuthorizeRouteView + default policy RequireSystemAdmin: todas las páginas requieren rol SystemAdmin; Login marcada [AllowAnonymous].
Panel Tenants: lista cross-tenant con status + atajos a API keys / audit.
POST /api/v1/admin/auth/login emite JWT cross-tenant (tid=Empty, audience normal). IssueAdminAccessToken con TTL 4× el de cliente.
AdminUserService + seed automático del SystemAdmin demo en Development (PBKDF2 SHA-256 vía PasswordHasher).
Panel Audit log por tenant con filtros (severity + action prefix) y paginación.
Panel API keys ERP: listar / crear / revocar por tenant con secret visible una sola vez.
Todos los endpoints /api/v1/admin/* ahora exigen rol SystemAdmin. /admin/logs/system pasa de Anonymous a Authorized.
TenantAdminService abre conexión propia a system/tenant DB (los endpoints admin no necesitan tenant resolver).
Blazor Admin con cookie auth (8h sliding) + Login.razor SSR + /logout endpoint + RedirectToLogin para no autenticados.
F7 — Visual polish + mapa
20/05/2026 Modo oscuro + alto contraste, login con carrusel y huella, MapAdapter (OSM real / Google + Mapbox stubs), mapa de visitas y ruta del día.
Pantalla Ruta del día con polyline + reorder de paradas y resumen navy.
tenantConfigProvider Riverpod expone la config del tenant al cliente (mapProvider, currency, locale, branding).
Pantalla Mapa de visitas con pins numerados, bottom sheet por cliente y empty state guiado.
DeviceId estable persistido en flutter_secure_storage; sobrevive a reinicios y reusa el refresh token.
Login revamp: carrusel horizontal de usuarios guardados + huella dactilar (local_auth) por usuario.
Modo oscuro completo + alto contraste con toggles funcionales en Configuración y persistencia en secure storage.
MapAdapter abstracto con OsmMapAdapter (flutter_map) real + stubs Google/Mapbox cableados al tenant.map.provider.
F6 — Pantallas core del cliente
18/05/2026 Clientes, Catálogo, Sync, Configuración + flujo de pedido en 3 pasos + push de pedidos al backend.
SyncEngine.pushUp(): envía pedidos pendientes al backend, idempotente por ClientLocalId.
Pantalla Clientes (CRM) con búsqueda, filtros por zona y avatares con iniciales.
Flujo de pedido en 3 pasos (cliente → productos → confirmar) con OrderDraft y totales en vivo.
Pantalla Sincronización con hero card, módulos + cursors y botón forzar sync total.
Pantalla Catálogo con chips de categoría, grid/lista, precio resuelto desde lista del cliente.
Pantalla Configuración con profile + roles + tenant + logout funcional.
Repositorios drift (CatalogRepository, OrdersRepository) y widgets compartidos.
F5 — Flutter shell
18/05/2026 App Flutter funcional end-to-end con login real, sync engine, persistencia local con drift y home shell.
AuthStateController con sesión persistida en flutter_secure_storage + bootstrap on start.
SyncEngine: pullDown con cursores por módulo, persiste rows en drift dentro de una transacción.
Dio + interceptor de auth: inyecta Bearer + X-Tenant-Id, refresh transparente en 401.
GoRouter auth-aware: redirect a /login según AuthSnapshot.
Drift local DB con 8 tablas + adaptador Android (NativeDatabase) y Web (memory).
Home shell: chip ONLINE/OFFLINE, sync manual, card de usuario con roles, bottom nav, FAB, logout.
Login screen funcional con backend real (credenciales demo pre-rellenadas).
F4 — APIs cliente + Sync
18/05/2026 Pedidos con outbox, sync down con cursor por módulo, dispatch ERP (pull/ack/reject) y config del tenant para la app.
MediatR ahora escanea Application + Infrastructure para registrar handlers que usan TenantDbContext.
POST /api/v1/orders idempotente por ClientLocalId, escribe outbox en la misma tx.
ISyncable + AuditingInterceptor estampan ChangedAtTicks automáticamente.
Endpoints HMAC /erp/v1/dispatch/orders + /ack + /reject para que la herramienta ERP consuma el outbox.
Modelo de pedido: Order + OrderLine + OutboxOrder con estados y attempts.
GET /api/v1/tenant/config: configuración por tenant (map provider, currency, locale, branding).
POST /api/v1/sync/down: deltas por módulo con cursor ChangedAtTicks + tombstones.
F3 — Ingesta ERP
17/05/2026 API key + HMAC, idempotencia, modelo de catálogo (5 recursos) y endpoints de ingesta + state watermarks.
Tabla api_keys_erp + IErpApiKeyService (crear/encontrar/revocar). Provisioning genera key inicial por tenant.
IdempotencyFilter: header Idempotency-Key obligatorio, cache de respuestas 24h, 409 ante reuso con body distinto.
Esquema de autenticación ErpHmac (API key + HMAC firmado + anti-replay).
Endpoints POST /erp/v1/ingest/{clients,products,categories,price-lists,prices}.
Validación HMAC con FixedTimeEquals + ventana de timestamp ±300s; reglas claras de canonicalización.
Modelo de catálogo: Client, Product, Category, PriceList, Price (con audit + soft delete).
GET /erp/v1/state: watermarks por recurso (TotalAccepted/Failed, LastIngestedAtUtc, LastCursor).
F2.5 — Slug-based tenant DB naming
17/05/2026 Cada tenant ahora tiene una columna Slug derivada del nombre, y el archivo .db usa ese slug en lugar del GUID.
docs/TODO.md creado como registro vivo de deuda técnica + memoria con regla de mantenimiento.
Columna tenants.slug única; nombre del archivo .db ahora <slug>.db (ej. demo-tenant.db).
Utilidad Domain.Tenancy.Slug.From (NFD + diacríticos + kebab-case + truncado a 64 chars).
F2 — Observabilidad + Resiliencia base
17/05/2026 Audit log automático, changelog persistido + endpoint, health checks, Polly policies, panel admin de logs.
Health checks: /healthz (liveness) y /readyz (system DB + disco + tenant DBs lazy).
CorrelationId middleware + Serilog enrichers (TenantId, UserId, CorrelationId, DeviceId).
Página admin Logs (system_log) con filtros y paginación.
Tablas changelog_releases + changelog_entries y endpoint GET /api/v1/changelog.
Polly policies nombradas (retry-exp, circuit-breaker, timeout) registradas vía IHttpClientFactory.
Página admin Changelog conectada a la BD del sistema.
File sink rotativo de Serilog (rolling diario).
AuditingInterceptor: timestamps automáticos en IAuditable + audit_log con before/after JSON.
F1 — Tenancy + Auth
17/05/2026 Multi-tenancy físico (BD por tenant), Identity + JWT con refresh rotativo y detección de reuso.
Refresh tokens rotativos vinculados a DeviceId, hash SHA-256 y detección de reuso.
CLI de migraciones: migrate-system, migrate-tenants, provision-tenant.
ASP.NET Core Identity sobre tenant DB + JWT access/refresh tokens.
Demo seed en Development: tenant 'demo' + admin demo@deam.local.
BD del sistema + BD por tenant (SQLite, un archivo por tenant).
Endpoints /api/v1/auth/login, /refresh, /logout, /whoami.
F0 — Scaffolding
17/05/2026 Estructura de solución, esqueleto de proyectos, theme Flutter 1:1, docker-compose y CI base.
Solución .NET 10 + Clean Architecture (8 proyectos + 4 de tests + CLI de migraciones).
docker-compose, GitHub Actions CI, Nerdbank.GitVersioning.
App Flutter (Android + Web) con tokens 1:1 desde el handoff de ClaudeDesign.