Changelog / Releases
Historial de versiones publicadas. Las entradas se siembran al arranque y se exponen también en GET /api/v1/changelog.
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.