Tenant:

Página global

Changelog / Releases

Historial de versiones publicadas. Las entradas se siembran al arranque y se exponen también en GET /api/v1/changelog.

1.37.0

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.

feat
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.

feat
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.

fix
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.

1.36.1

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.

fix
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.

1.36.0

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).

ui
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.

feat
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.

feat
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.

ui
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.

1.35.0

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.

feat
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.

fix
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.

1.34.1

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.

fix
Corregido: la etiqueta de los selectores de formato quedaba superpuesta al texto de la opción seleccionada cuando estaba elegido el valor por defecto.

ui
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ú.

1.34.0

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.

feat
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).

feat
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.

feat
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.

1.33.0

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.

ui
Configuración → Zona horaria: el desplegable ahora muestra la lista completa de zonas al abrirlo aunque ya haya una seleccionada; escribir filtra la lista.

feat
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.

1.32.0

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.

feat
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.

fix
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.

feat
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.

1.31.6

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.

fix
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.

1.31.5

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.

fix
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".

fix
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).

ui
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".

feat
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.

1.31.4

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.

fix
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.

infra
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.

1.31.3

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).

fix
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.

fix
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.

1.31.2

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).

fix
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.

fix
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.

1.31.1

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.

fix
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).

fix
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.

fix
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.

fix
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.

infra
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).

fix
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).

1.31.0-alpha

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.

data
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.

feat
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.

data
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).

feat
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.

feat
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.

feat
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).

data
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).

1.30.0-alpha

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.

docs
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.

feat
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.

feat
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.

feat
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}.

feat
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.

feat
Router: GoRoute /notifications ahora pasa state.uri.queryParameters['highlight'] al constructor de NotificationsScreen. URL deep-link: /notifications?highlight=GUID.

feat
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().

feat
_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.

feat
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.

1.29.0-alpha

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.

feat
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).

feat
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().

docs
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.

feat
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.

feat
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'.

feat
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
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.

feat
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).

feat
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).

security
.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).

feat
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.

feat
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.

1.28.0-alpha

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).

feat
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.

feat
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.

feat
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).

feat
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.

docs
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).

test
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.

feat
DTO EvaluatedFeatureFlagsResponse en Shared.Contracts.FeatureFlags. Inmutable, Dictionary<string, bool> + DateTimeOffset. La lógica de rollout/bucket queda centralizada server-side.

feat
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 }.

test
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).

1.27.0-alpha

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.

feat
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).

feat
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.

test
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).

feat
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).

feat
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.

docs
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.

feat
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).

feat
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.

1.26.0-alpha

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).

feat
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
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.

feat
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.

feat
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).

feat
PushOptions en DeamPreventaOptions: Enabled (default true), AutoPushMinSeverity (default Critical), FirebaseServiceAccountPath (default vacío = stub mode). Configurable via DeamPreventa:Push:*.

docs
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.

feat
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).

feat
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.

feat
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.

feat
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).

1.25.0-alpha

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
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
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.

feat
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).

feat
ITenantSmtpConfigService + ISmtpSecretProtector (purpose DEAM.Preventa.SmtpPassword.v1, análogo a ErpSecretProtector con purpose aislado para no chocar rotaciones de keys).

infra
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).

docs
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).

feat
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.

feat
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.

security
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.

feat
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.

feat
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.

1.24.0-alpha

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.

feat
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.

security
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.

feat
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.

feat
Nav link 'Admins' en MainLayout, entre Tenants y Usuarios, con ícono AdminPanelSettings.

feat
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.

feat
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.

docs
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.

feat
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.

1.23.0-alpha

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).

docs
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
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.

feat
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.

feat
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).

1.22.0-alpha

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.

infra
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
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).

docs
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.

infra
Eliminado _StubProvider de map_adapter.dart (era usado solo por Mapbox; el resto de paths ya degradaban a OSM tras F8.25).

1.21.0-alpha

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.

docs
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.

infra
minSdk subido a max(flutter.minSdkVersion, 21) — google_maps_flutter lo requiere. Blindaje por si el floor de Flutter baja en algún update.

feat
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.

feat
2 widget tests nuevos en map_adapter_test.dart: GoogleMapAdapter con apiKey vacía o null degrada a un widget FlutterMap (verifica el fallback).

feat
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.

feat
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.

feat
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).

feat
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.

infra
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.

1.20.0-alpha

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.

feat
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.

feat
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.

docs
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).

feat
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.

feat
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.

infra
Migración VisitEvents (tabla visit_events + 5 índices: ClientLocalId unique, PreventistaExternalId, RouteStopId, OccurredAtUtc, ChangedAtTicks).

feat
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.

1.19.0-alpha

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.

feat
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.

security
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).

security
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.

docs
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).

feat
ErpRateLimitOptions (PermitLimit + WindowSeconds) configurables via DeamPreventa:ErpRateLimit:*.

1.18.0-alpha

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.

feat
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.

infra
SqliteConnection.ClearAllPools() antes del rename para liberar handles pooled que podrían lockear el .tmp.

feat
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.

feat
BackupOptions: Enabled=true, IntervalHours=24, RetainCount=14, BackupsRoot=data/_backups. Layout: <Root>/system/{ts}.db + <Root>/tenants/<slug>/{ts}.db.

docs
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.

feat
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.

feat
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.

1.17.0-alpha

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.

docs
TODO: 2 P1 cerrados en F2 (Sink Serilog → system_log y política de retención).

feat
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.

feat
Sink síncrono con try/swallow defensivo — failures nunca propagan a la pipeline. Console + File siguen como redundancia.

infra
DI explícito de OutboxRecoveryJob + RetentionPurgeJob como transient services para que tests puedan resolverlos y ejecutar Execute() sin pasar por el scheduler.

infra
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.

feat
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.

feat
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.

fix
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).

infra
RetentionOptions defaults: AuditLogDays=180, SystemLogDays=60, IntervalHours=24. SystemLogSinkOptions: Enabled=true, MinimumLevel=Warning.

1.16.0-alpha

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.

feat
pubspec.yaml registra las familias Inter y JetBrainsMono. AppFonts.sans/mono ya apuntaban a esos nombres desde F0.

feat
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.

feat
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.

docs
TODO: 4 P0s cerrados (3 entradas de fuentes en F0/F5/F7 + sync up en F5).

1.15.0-alpha

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.

feat
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.

feat
Error record extendido con LockedUntilUtc init-only. AuthErrors.LockedOut(until) factory. ToResponse mapea Auth.LockedOut a 423 + Retry-After computado.

security
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).

fix
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.

security
Rate limiting en /api/v1/auth/login: FixedWindow por IP (60 req/min). Espejo del policy admin-login de F8.7 con bucket independiente.

docs
TODO: 3 items P1/P2 cerrados en F1 (rate limit, logout-all-devices, lockout surfacing 423).

feat
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.

1.14.0-alpha

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.

feat
Endpoint GET /api/v1/admin/tenants/{id}/audit/{entryId:long} bajo policy SystemAdmin; 404 para tenant/entry desconocidos.

feat
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.

feat
AuditLogEntryDetailDto incluye Before y After JSON. ITenantAdminService.GetAuditEntryAsync devuelve el detalle on-demand (no en la lista paginada, por tamaño).

docs
TODOs cerrados: F2 'Página admin para audit_log' pasa de [~] a [x]; F8 'Drill-down al diff before/after' cerrado.

feat
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).

1.13.0-alpha

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.

feat
ITenantAdminService.DeleteOutboxAsync: borra fila del outbox (sólo DeadLetter). Order queda intacto para preservar audit y FKs.

docs
TODO: P1 'Acciones de mutación en /outbox' cerrado. F8.11 actualiza el sub-pendiente 'panel admin con vista de DeadLetter' a cerrado.

feat
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.

feat
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.

feat
Outbox.razor: columna Acciones nueva con botones Reintentar/Eliminar para filas DeadLetter. Delete confirma con IDialogService.ShowMessageBox; _actionInFlight evita doble-click.

feat
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.

1.12.0-alpha

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.

feat
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.

feat
Tenants.razor suma deep-links a /users y /settings con ?tenantId= pre-cargado.

feat
Settings.razor: form completo en dos columnas (mapa / localización + branding) con preview en vivo de hex colors. Botones Guardar/Descartar.

docs
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.

feat
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.

feat
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.

feat
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).

feat
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.

feat
Users.razor: tabla con filtros + diálogo de creación + modal de password 'una sola vez' para create-generado y reset-password. Acciones inline.

1.11.0-alpha

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.

feat
Sync.razor: cards de resumen (total recursos + idempotency keys activas) + tabla por recurso con TotalAccepted/Failed/LastIngestedAtUtc/LastCursor.

feat
DTOs en Shared.Contracts/Admin: OrderSummaryDto/OrderLineDto/OrderDetailDto/OrderPageDto, OutboxRowDto/OutboxPageDto/OutboxCountersDto, SyncWatermarkDto/SyncOverviewDto.

feat
Endpoints GET /api/v1/admin/tenants/{id}/orders[/{orderId}], /outbox, /sync — todos bajo policy SystemAdmin, devuelven 404 para tenant inexistente.

docs
TODOs nuevos: acciones retry/delete en /outbox, /feature-flags desde cero, /settings con PUT auditado. /users sigue cubierto por item P1 existente.

feat
Outbox.razor: counters por estado en el header (Pending/InTransit/Acked/Rejected/DeadLetter), fila de DeadLetter resaltada con fondo rojo claro, truncado de LastError.

feat
ITenantAdminService extendido con GetOrdersAsync/GetOrderDetailAsync/GetOutboxAsync/GetSyncOverviewAsync. Enriquecimiento con LineCount via join agrupado y conteo de outbox por estado.

feat
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.

feat
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.

feat
Tenants.razor: 3 botones nuevos por fila (Pedidos/Outbox/Sync) con ?tenantId= pre-cargado para navegación directa.

1.10.0-alpha

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.

feat
AppUser.MustChangePassword + migración UserMustChangePassword. UserIngestStrategy lo setea cuando crea un user con password generado random.

feat
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.

infra
CLI tools/DEAM.Preventa.Migrations seed-system-admin --email X --password Y para sembrar/asegurar el SystemAdmin en Production sin tocar appsettings.json. Idempotente.

feat
Flutter ChangePasswordScreen + ruta /change-password + AuthStateController.changePassword. Router gate fuerza la pantalla mientras mustChangePassword esté activo.

security
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.

infra
appsettings.json depurado: passwords + SigningKey vacíos (placeholders + comentarios _xxx_comment con instrucciones). appsettings.Development.json mantiene los valores DX.

feat
JWT incluye claim must_change_password=true cuando aplica. AuthenticatedUserDto lo expone; LoginCommand y RefreshTokenCommand lo propagan.

1.9.0-alpha

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.

feat
UserIngestDto + UserIngestStrategy + POST /erp/v1/ingest/users. Clave de negocio: ExternalId; Email es UserName y puede cambiar entre ingests sin colisionar.

security
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.

infra
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).

infra
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.

docs
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).

feat
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).

security
Update con IsActive=false (sin tombstone) también revoca tokens — mismo comportamiento que el tombstone explícito.

feat
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.

1.8.0-alpha

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.

feat
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).

feat
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).

infra
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.

feat
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.

feat
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).

feat
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.

feat
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.

feat
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.

feat
PromoBanner con severity Info/Success/Warning/Highlight + ventana ValidFrom/To + ingest /erp/v1/ingest/promo-banners.

feat
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.

1.7.0-alpha

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.

feat
OutboxRecoveryJob (Quartz, [DisallowConcurrentExecution]): itera todos los tenants y crea scope DI con ITenantContext seteado por tenant; continúa ante fallo de uno individual.

infra
Quartz scheduler cableado por primera vez en DependencyInjection: AddQuartz + AddQuartzHostedService(WaitForJobsToComplete).

infra
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.

feat
OutboxRecoveryOptions (Enabled, IntervalSeconds=300, StaleAfterMinutes=30, MaxAttempts=10, BatchSize=500) — todos overrideables desde appsettings.

feat
IOutboxRecoveryService: por tenant, mueve InTransit con LastAttemptAtUtc < UtcNow − StaleAfterMinutes a Pending; promueve a DeadLetter si Attempts >= MaxAttempts.

1.6.0-alpha

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.

feat
Order.PaymentMethodCode + validación en handler (404 si el código no existe o está inactivo). DispatchOrderDto y OrderResponse lo exponen.

feat
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).

feat
Flutter: CatalogF35Repository con queries para stock-por-sku (sumado entre warehouses), payment methods activos, discount rules percent, taxes activos + sumActiveTaxRates.

feat
StockBadge widget (verde/ámbar/rojo) integrado en CatalogScreen (grid+list) y en step 2 del pedido (deshabilita 'Agregar' si SKU sin stock).

feat
OrderDraft expone taxRateSum + taxableBase + taxTotal; setPaymentMethodCode y setTaxRateSum mantienen el estado del step 3.

feat
Drift v2 → v3: orders.paymentMethodCode persistido localmente y enviado al backend al hacer push.

feat
CreateOrderCommandHandler aplica TaxTotal = (Subtotal − Discount) × sum(rates de taxes activos); Total = base + TaxTotal.

1.5.0-alpha

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.

feat
DTOs StockSyncDto, TaxSyncDto, PaymentMethodSyncDto, DiscountRuleSyncDto + 4 cursores nuevos en SyncCursors.

feat
SyncDownService.ExecuteAsync emite las 4 listas con paginación y cursor por ChangedAtTicks; HasMore considera los 10 módulos.

feat
Drift schemaVersion 1→2 con onUpgrade que crea Stocks/Taxes/PaymentMethods/DiscountRules en la BD local del cliente.

docs
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).

feat
Pantalla Sync del Flutter ahora lista los 10 módulos (Stock, Impuestos, Medios de pago, Descuentos).

feat
SyncEngine.pullDown persiste las 4 listas en transacción con tombstones (delete on isDeleted) y actualiza los cursores correspondientes.

1.4.0-alpha

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).

feat
DiscountRules (Code, Name, Type 'percent'/'amount', Value, MinQuantity opcional). POST /erp/v1/ingest/discount-rules con validación de tipo y rango.

feat
Stock (WarehouseCode + Sku) con Quantity + ReservedQuantity. POST /erp/v1/ingest/stock.

docs
TODO actualizado: queda F3.5 parte 2 (Routes/RouteStops, PromoBanners, Notifications, Users) y el cableado del sync down a Flutter.

infra
Migración CatalogF35 crea las 4 tablas + índices únicos por clave de negocio + índices sobre ChangedAtTicks.

feat
Taxes (Code, Name, Rate decimal en [0,1], IsActive). POST /erp/v1/ingest/taxes con validación de rango.

feat
PaymentMethods (Code, Name, SortOrder, IsActive). POST /erp/v1/ingest/payment-methods.

1.3.0-alpha

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).

security
Rate limit por IP en /api/v1/admin/auth/login (FixedWindowLimiter, 60 req/min). Excedido → 429 con Retry-After.

feat
AdminLoginOutcome distingue Success / LockedOut / InvalidCredentials. Usuario inexistente devuelve 401 (no revela existencia).

feat
AdminLockedOutDto + header Retry-After en respuesta 423. El Login.razor del Admin muestra 'Reintentá en ~N min' cuando aplica.

infra
Migración AdminLockout agrega las dos columnas nuevas a admin_users.

security
Lockout por cuenta: FailedLoginAttempts + LockoutUntilUtc en AdminUserRecord. Tras 5 fallos consecutivos, 423 Locked durante 10min.

1.2.0-alpha

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.

infra
Data Protection registrado con SetApplicationName + PersistKeysToFileSystem(DataProtectionKeysPath); default data/dp-keys configurable.

infra
Migración EncryptHmacSecret amplía MaxLength a 1024 chars (no-op en SQLite por type affinity TEXT).

feat
IErpSecretProtector encapsula Protect/Unprotect; legacy plain-text se detecta por ausencia de prefijo y se upgradea lazy en el primer FindActiveAsync.

security
ErpApiKey.HmacSecret se persiste cifrado con prefijo enc.v1: vía IDataProtector (purpose DEAM.Preventa.ErpApiKey.v1).

1.1.0-alpha

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.

infra
SystemDbInitializerHostedService y TenantProvisioner ahora aplican migraciones vía MigrateAsync() en lugar de EnsureCreatedAsync().

infra
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).

feat
IDesignTimeDbContextFactory<SystemDbContext> e IDesignTimeDbContextFactory<TenantDbContext> habilitan dotnet ef migrations add / database update.

feat
Migración Initial generada para System (6 tablas) y Tenant (Identity + refresh_tokens + audit_log + catálogo + idempotency + watermarks + orders + outbox).

1.0.0-alpha

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.

feat
AuthorizeRouteView + default policy RequireSystemAdmin: todas las páginas requieren rol SystemAdmin; Login marcada [AllowAnonymous].

feat
Panel Tenants: lista cross-tenant con status + atajos a API keys / audit.

feat
POST /api/v1/admin/auth/login emite JWT cross-tenant (tid=Empty, audience normal). IssueAdminAccessToken con TTL 4× el de cliente.

feat
AdminUserService + seed automático del SystemAdmin demo en Development (PBKDF2 SHA-256 vía PasswordHasher).

feat
Panel Audit log por tenant con filtros (severity + action prefix) y paginación.

feat
Panel API keys ERP: listar / crear / revocar por tenant con secret visible una sola vez.

security
Todos los endpoints /api/v1/admin/* ahora exigen rol SystemAdmin. /admin/logs/system pasa de Anonymous a Authorized.

infra
TenantAdminService abre conexión propia a system/tenant DB (los endpoints admin no necesitan tenant resolver).

feat
Blazor Admin con cookie auth (8h sliding) + Login.razor SSR + /logout endpoint + RedirectToLogin para no autenticados.

0.9.0-alpha

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.

feat
Pantalla Ruta del día con polyline + reorder de paradas y resumen navy.

feat
tenantConfigProvider Riverpod expone la config del tenant al cliente (mapProvider, currency, locale, branding).

feat
Pantalla Mapa de visitas con pins numerados, bottom sheet por cliente y empty state guiado.

feat
DeviceId estable persistido en flutter_secure_storage; sobrevive a reinicios y reusa el refresh token.

feat
Login revamp: carrusel horizontal de usuarios guardados + huella dactilar (local_auth) por usuario.

feat
Modo oscuro completo + alto contraste con toggles funcionales en Configuración y persistencia en secure storage.

feat
MapAdapter abstracto con OsmMapAdapter (flutter_map) real + stubs Google/Mapbox cableados al tenant.map.provider.

0.8.0-alpha

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.

feat
SyncEngine.pushUp(): envía pedidos pendientes al backend, idempotente por ClientLocalId.

feat
Pantalla Clientes (CRM) con búsqueda, filtros por zona y avatares con iniciales.

feat
Flujo de pedido en 3 pasos (cliente → productos → confirmar) con OrderDraft y totales en vivo.

feat
Pantalla Sincronización con hero card, módulos + cursors y botón forzar sync total.

feat
Pantalla Catálogo con chips de categoría, grid/lista, precio resuelto desde lista del cliente.

feat
Pantalla Configuración con profile + roles + tenant + logout funcional.

feat
Repositorios drift (CatalogRepository, OrdersRepository) y widgets compartidos.

0.7.0-alpha

F5 — Flutter shell

18/05/2026

App Flutter funcional end-to-end con login real, sync engine, persistencia local con drift y home shell.

feat
AuthStateController con sesión persistida en flutter_secure_storage + bootstrap on start.

feat
SyncEngine: pullDown con cursores por módulo, persiste rows en drift dentro de una transacción.

feat
Dio + interceptor de auth: inyecta Bearer + X-Tenant-Id, refresh transparente en 401.

feat
GoRouter auth-aware: redirect a /login según AuthSnapshot.

feat
Drift local DB con 8 tablas + adaptador Android (NativeDatabase) y Web (memory).

feat
Home shell: chip ONLINE/OFFLINE, sync manual, card de usuario con roles, bottom nav, FAB, logout.

feat
Login screen funcional con backend real (credenciales demo pre-rellenadas).

0.6.0-alpha

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.

infra
MediatR ahora escanea Application + Infrastructure para registrar handlers que usan TenantDbContext.

feat
POST /api/v1/orders idempotente por ClientLocalId, escribe outbox en la misma tx.

feat
ISyncable + AuditingInterceptor estampan ChangedAtTicks automáticamente.

feat
Endpoints HMAC /erp/v1/dispatch/orders + /ack + /reject para que la herramienta ERP consuma el outbox.

feat
Modelo de pedido: Order + OrderLine + OutboxOrder con estados y attempts.

feat
GET /api/v1/tenant/config: configuración por tenant (map provider, currency, locale, branding).

feat
POST /api/v1/sync/down: deltas por módulo con cursor ChangedAtTicks + tombstones.

0.5.0-alpha

F3 — Ingesta ERP

17/05/2026

API key + HMAC, idempotencia, modelo de catálogo (5 recursos) y endpoints de ingesta + state watermarks.

feat
Tabla api_keys_erp + IErpApiKeyService (crear/encontrar/revocar). Provisioning genera key inicial por tenant.

feat
IdempotencyFilter: header Idempotency-Key obligatorio, cache de respuestas 24h, 409 ante reuso con body distinto.

feat
Esquema de autenticación ErpHmac (API key + HMAC firmado + anti-replay).

feat
Endpoints POST /erp/v1/ingest/{clients,products,categories,price-lists,prices}.

security
Validación HMAC con FixedTimeEquals + ventana de timestamp ±300s; reglas claras de canonicalización.

feat
Modelo de catálogo: Client, Product, Category, PriceList, Price (con audit + soft delete).

feat
GET /erp/v1/state: watermarks por recurso (TotalAccepted/Failed, LastIngestedAtUtc, LastCursor).

0.4.0-alpha

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
docs/TODO.md creado como registro vivo de deuda técnica + memoria con regla de mantenimiento.

feat
Columna tenants.slug única; nombre del archivo .db ahora <slug>.db (ej. demo-tenant.db).

feat
Utilidad Domain.Tenancy.Slug.From (NFD + diacríticos + kebab-case + truncado a 64 chars).

0.3.0-alpha

F2 — Observabilidad + Resiliencia base

17/05/2026

Audit log automático, changelog persistido + endpoint, health checks, Polly policies, panel admin de logs.

infra
Health checks: /healthz (liveness) y /readyz (system DB + disco + tenant DBs lazy).

feat
CorrelationId middleware + Serilog enrichers (TenantId, UserId, CorrelationId, DeviceId).

feat
Página admin Logs (system_log) con filtros y paginación.

feat
Tablas changelog_releases + changelog_entries y endpoint GET /api/v1/changelog.

infra
Polly policies nombradas (retry-exp, circuit-breaker, timeout) registradas vía IHttpClientFactory.

feat
Página admin Changelog conectada a la BD del sistema.

feat
File sink rotativo de Serilog (rolling diario).

feat
AuditingInterceptor: timestamps automáticos en IAuditable + audit_log con before/after JSON.

0.2.0-alpha

F1 — Tenancy + Auth

17/05/2026

Multi-tenancy físico (BD por tenant), Identity + JWT con refresh rotativo y detección de reuso.

security
Refresh tokens rotativos vinculados a DeviceId, hash SHA-256 y detección de reuso.

feat
CLI de migraciones: migrate-system, migrate-tenants, provision-tenant.

feat
ASP.NET Core Identity sobre tenant DB + JWT access/refresh tokens.

feat
Demo seed en Development: tenant 'demo' + admin demo@deam.local.

feat
BD del sistema + BD por tenant (SQLite, un archivo por tenant).

feat
Endpoints /api/v1/auth/login, /refresh, /logout, /whoami.

0.1.0-alpha

F0 — Scaffolding

17/05/2026

Estructura de solución, esqueleto de proyectos, theme Flutter 1:1, docker-compose y CI base.

feat
Solución .NET 10 + Clean Architecture (8 proyectos + 4 de tests + CLI de migraciones).

infra
docker-compose, GitHub Actions CI, Nerdbank.GitVersioning.

feat
App Flutter (Android + Web) con tokens 1:1 desde el handoff de ClaudeDesign.