MF-10 · notificaciones · sep 2026

Mobile · push y centro de notificaciones

La app que avisa.

Hasta hoy la app se entera preguntando; con MF-10, Mogos toca la puerta. El permiso se pide con valor — un pre-prompt propio que deja virgen el del sistema. La push en primer plano es un toast Sileo con «Ver» y la campana subiendo al instante; en background, tocarla aterriza en el envío, el flete, el pago o la cotización vía el mapa actionUrl web → ruta app que MF-03 dejó listo. El centro deja el polling de 60 s, el ícono cuenta lo mismo que la campana, Android estrena cinco canales calcados de las categorías web y los ajustes viven en Más. Push es greenfield (DECISIONS #7): la fase backend — DeviceToken + dispatch con expo-server-sdk — queda especificada, no implementada. Y el inventario corrigió el brief: son 31 tipos, no 38, y el API no localiza — traduce el teléfono.

Tipos
31 (5 sin emisor)
Deep links
9 mapeados · 2 a lista
Canales Android
5 = categorías web
Foreground
toast Sileo + invalidación
Backend
fase documentada, HOLD
Pantallas
9 a 390×844

01El permiso, pedido con valor

usePushPermission · pre-prompt sheet · Notifications.requestPermissionsAsync · Linking.openSettings

El prompt del sistema se puede mostrar una sola vez en la vida de la instalación — quemarlo en frío es perderlo. Por eso la app pregunta primero con su propia voz: una hoja con la cabecera noche del sheet de crear (MF-03), tres razones concretas en el idioma del usuario y dos salidas honestas. «Activar notificaciones» es lo único que dispara el prompt nativo; «Ahora no» cierra sin quemar nada y no vuelve a asaltar — la re-oferta queda contextual, arriba del centro. La hoja aparece una vez, en el primer foreground después de la admisión completa (sesión + rol + onboarding, después del modal de bienvenida de MF-03), nunca antes de que el usuario haya visto su casa. Sin permiso, la app funciona entera: campana, centro y contador no dependen del push.

Pre-prompt una vez · primer foreground tras la admisión
El prompt del sistema solo tras el CTA · una vez en la vida
Denegado el centro ofrece el camino de vuelta
  1. El pre-prompt no es un modal del sistema

    Es la casa hablando: cabecera noche (--p-grad-rail) con el roundel de campana en --grad-marca, tres filas con los plates tonales de las categorías — envíos en celeste-soft (el plate noche de shipping oscurecería la hoja), pagos en success-soft, avisos en warning-soft — y el CTA .b-def de 48. Copy nuevo con claves mobile.pushPrompt.* es/en/zh — no hay paridad que respetar: la web no tiene push.

  2. «Ahora no» respeta y recuerda

    Se guarda mogos.push.promptDismissedAt.{userId} en MMKV y la hoja no reaparece. La re-oferta vive en el banner del centro (el mismo del estado denegado, con copy de invitación) — el usuario llega ahí justo cuando está mirando notificaciones: el único momento donde el pitch es obvio.

  3. Denegado no es un callejón

    Si el permiso del sistema quedó en denied, el banner cambia a «desactivadas» + «Abrir Ajustes» (Linking.openSettings()): iOS y Android aterrizan en la ficha de la app. Warning-soft con tinta warning-deep (AA), nunca rojo — no es un error, es una elección reversible.

  4. El estado vive en un solo hook

    usePushPermission lee getPermissionsAsync al foreground (AppState): si el usuario activó el permiso en Ajustes, el banner desaparece y el token se registra solo, sin pedirle nada. canAskAgain=false es lo que decide banner de Ajustes vs pre-prompt.

02La push llega

setNotificationHandler · toast Sileo (MF-01) · openNotificationTarget · setBadgeCountAsync · canales Android

Con la app abierta, el banner del sistema se apaga (setNotificationHandler) y la push habla con la voz de la casa: el toast Sileo de MF-01, expandido, con el título localizado y la acción «Ver» — que pasa por openNotificationTarget y marca leída. En el mismo instante se invalida la familia ["notifications"]: la campana sube con su wiggle de 12° y el centro, si está abierto, se refresca. Con la app en background o cerrada, el sistema muestra la notificación con el ícono de Mogos; tocarla abre la app directo en la entidad — envío, flete, pago, cotización — y si la ruta no existe todavía, en la lista de notificaciones (decisión nueva: el fallback deja de ser el Inicio). El globo del ícono dice siempre lo mismo que la campana.

  1. El toast es el de MF-01, expandido

    La píldora Sileo con description se expande: ícono de la categoría en el circulito tonal, título en el color del tono (info), descripción a una línea con ellipsis y la acción «Ver» como chip de borde. Cae con el drop de 640 ms en --ease-spring, se va sola a los 4 s. La fachada lib/toast.ts se amplía: hoy toast.info no acepta opciones — MF-10 le enseña {description, action, icon}.

  2. El texto llega ya localizado — y se re-localiza

    El payload trae {notificationId, actionUrl, type} y el texto del idioma del DeviceToken. Al presentarlo, la app lo pasa por localizeNotification (el sobre metadata.i18n ya portado en lib/notifications/localize.ts): si el usuario cambió de idioma después del registro, el toast sale bien igual.

  3. «Ver» y el tap hacen lo mismo

    Toast en foreground o notificación del sistema en background: ambos van a openNotificationTarget(actionUrl) — mapWebRouteToApp, marcar leída con el notificationId, navegar. Arranque en frío sin sesión: el stash de attempted-href de MF-03 guarda el destino y lo retoma tras el login.

  4. La campana no espera a nadie

    Recibir la push (aun sin tocarla) invalida ["notifications"]: el contador sube al instante y suena el wiggle de 12° que MF-03 ya tenía cableado para cuando el número crece. Nada de esperar el poll.

  5. El globo del ícono es el unread-count

    Doble vía: el dispatch manda badge (el conteo del usuario al momento del envío) en cada push — el sistema lo pinta aunque la app esté muerta — y la app reconcilia con setBadgeCountAsync(count) cada vez que la query unread-count cambia: foreground, marcar leída, marcar todas. Logout → 0.

El globo en el springboard badge = unread-count · cromo del sistema

El ícono es el monograma vector sobre marino (el de MF-00). El globo rojo es cromo del sistema (--ios-badge, declarado): ahí no manda la marca. En Android el badge es el dot/número del launcher — sale gratis del mismo badge del payload.

El aterrizaje tap → detalle del flete · back vuelve al shell

La push de la maqueta (FREIGHT_TRANSIT_STOP) trae actionUrl /account/freights/{id} → freights/[id] de MF-06, con su back al shell. Las rutas ya mapeadas aterrizan así. ACCOUNT_CREATED (/complete-profile, a menudo con ?source=oauth) abre el flujo complete-profile que ya existe desde MF-02 (app/(auth)/complete-profile). Hoy navigate.ts lo trata como no mapeado y abre el Inicio; MF-10 lo retargetea. Solo /account/storage/{id} y /account/mogos-id abren la lista hasta que MF-16 tenga esas pantallas, y entonces se retargetean. Nunca un 404, nunca una pantalla en blanco.

Canal AndroidCategoría webQué llegaImportancia
Estado de envíosstatusORDER_STATUS_CHANGED (creada, asignada, aprobada, rechazada, estado)Default · HIGH sube a Max
Fletes y entregasshippingFREIGHT_STATUS, TRANSIT_STOP/DEPARTED/SKIPPED, DELIVERY_COMPLETED, SIGNATURE_REQUIREDDefault · HIGH/URGENT → Max + sonido
PagospaymentPAYMENT_RECEIVED/CONFIRMED, QUOTATION_CREATED/APPROVEDDefault
AlmacenajestorageSTORAGE_* de cliente (reserva, modificación, retiro)Default
Avisos de MogossystemNEW_CATALOG, ACCOUNT_CREATED, PROFILE_COMPLETED, WALLET_PASS_READYLow · sin sonido

Los cinco canales se crean al registrar el token, con nombres localizados es/en/zh. El usuario los apaga por canal desde Ajustes de Android — gratis, sin backend. En iOS, URGENT queda anotado para interruptionLevel: timeSensitive (pide entitlement; se decide en la fase de credenciales).

03El centro deja el polling

use-unread-count.ts:16 · «Until MF-10 brings realtime…» · broadcast notifications-user-{id} · AppState

El centro de MF-03 no cambia de cara — banda de día, pestañas, filtros, cards con el canto marino — cambia de motor. Hoy la campana pregunta cada 60 segundos; con MF-10 la respuesta viaja sola: cada push recibida invalida ["notifications"], y el canal por usuario cubre lo que pase con la app abierta: broadcast en el mismo choke point, el patrón que MF-13 dejó probado en sugerencias (decisión cerrada, sin migración; el evento solo invalida). Al volver a foreground, un refetch reconcilia lo perdido mientras el socket dormía. El poll no muere del todo: queda de red de seguridad a 120 s, como propone el spec web aprobado. Y el centro gana lo que le faltaba: pull-to-refresh y el pie de reintento del stack compartido.

  1. La fila que acaba de llegar respira una vez

    Entra con slide + fade de 260 ms en --ease-out y un anillo celeste que se disuelve en 2,4 s. Nada parpadea, nada se reordena a la fuerza: la invalidación de TanStack repinta la primera página y la fila nueva encabeza. Reduced motion: aparece sin animación, el anillo no se pinta.

  2. Un solo idioma de invalidación

    Push recibida → invalidateQueries(["notifications"]) (cubre lista, infinita y contador de un golpe — las keys de MF-03 ya cuelgan del mismo prefijo). Broadcast del canal → lo mismo. Foreground → refetchQueries del contador y la primera página. El interval baja a 120 s como último recurso, refetchIntervalInBackground sigue en false.

  3. Lo que el centro gana de la pila compartida

    Pull-to-refresh nativo y el pie de reintento de InfiniteListFooter cuando una página falla — los dos existían en components/lists y el centro de MF-03 no los usaba (deuda de Ley #28, anotada; la adopción completa del stack queda para consolidate).

  4. Tocar una fila sin destino no expulsa

    En el centro ya estás en la lista: una fila cuyo actionUrl no mapea (storage, mogos-id) se marca leída y muestra el toast «Este contenido aún no está en la app» — no navega al Inicio como hoy. El fallback de navegación es para la push.

  5. Todo lo demás queda igual

    Sheet de la campana, swipe para leer/borrar, action sheet de la card, filtros, vacíos: MF-03 tal cual — las fases Done no se reabren. MF-10 solo toca el motor y estos dos huecos.

04Los ajustes, en Más

mas.tsx › Preferencias · Switch de mobile-ui (trailing) · POST/DELETE /users/me/device-tokens

Una fila nueva en Más › Preferencias, después de Idioma — MF-10 solo agrega, no reordena (la ley de MF-13). El meta dice la verdad de un vistazo: «Activadas», «Desactivadas» o «En Ajustes». Adentro, la pantalla dice el estado del permiso del sistema, un switch global que registra o borra el device token — apagarlo corta el push de verdad — y cuatro grupos de cliente: envíos, fletes, pagos y avisos. Decisión cerrada: se construye NotificationPreference ahora y el filtro corre en el choke point del dispatch. Lo que Android da gratis por canal, iOS no lo tiene — por eso el switch global vive en la app.

Más › Preferencias la fila nueva, aditiva
Notificaciones · activadas switch global + 4 grupos
Notificaciones · en Ajustes denegado en el sistema
  1. El switch de mobile-ui, primera vez en una lista

    Switch con controlPosition="trailing" — el arreglo que su propia doc llama «settings-list» y que ningún flujo usaba aún. Fila de 44, plate de 36, track brand-700. Apagarlo hace DELETE /users/me/device-tokens y borra el badge; encenderlo re-registra. Optimista, con rollback y toast de error de la casa.

  2. El meta de la fila no miente

    «Activadas» = permiso ok + token registrado · «Desactivadas» = el usuario apagó el switch · «En Ajustes» = el sistema lo tiene denegado. Son los tres estados reales de usePushPermission × token — no hay cuarto.

  3. Cuatro grupos, además del switch global

    Decisión cerrada: NotificationPreference se construye en esta fase. Los grupos de cliente son envíos, fletes, pagos y avisos, más el switch global. El filtro está en el choke point del dispatch. Los cinco canales Android no cambian; almacenaje sigue siendo canal, no un quinto switch.

  4. Sin pantalla de Ajustes general

    MF-09 decidió paridad estricta: no hay «Ajustes» como pantalla madre. Notificaciones entra como fila hermana de Idioma, con su propio stack — el mismo patrón que la web usará cuando le toque (deuda anotada allá: la web no tiene ni esto).

05El mapa: 31 tipos, 11 destinos

NotificationTypeEnum (schema.prisma:3828) · notification-icons.ts · web-route-map.ts · navigate.ts

La verdad medida, no la del brief: 31 tipos, de los cuales 20 le llegan a un cliente, 6 son solo de staff (no se despachan al teléfono — y dos emisores de staff ni siquiera llevan el sello de audiencia: el dispatch necesita su propia lista de exclusión) y 5 no tienen emisor. El ícono y el plate de cada fila ya los resolvió MF-03 con el mapa de notification-icons.ts; lo que MF-10 agrega es la segunda columna del mapa: a dónde aterriza cada push. Once actionUrl distintos llegan a clientes. Los destinos de cliente que ya estaban mapeados se quedan: envío, flete, firma, pagos, cotización, documentos y catálogo, más /account para PROFILE_COMPLETED (Inicio). ACCOUNT_CREATED no cae a la lista: la pantalla complete-profile ya existe (MF-02) y MF-10 la abre. Solo storage y Mogos ID siguen sin pantalla.

actionUrl (web)Quién lo emiteAterriza enEstado
/account/my-shipments/{id}ORDER_STATUS_CHANGED (creada, asignada, estado, aprobada, rechazada)my-shipments/[id] · MF-04mapeado
/account/freights/{id}FREIGHT_STATUS_CHANGED · FREIGHT_TRANSIT_STOP/DEPARTED/SKIPPEDfreights/[id] · MF-06mapeado
/account/freights/{id}/delivery?token=SIGNATURE_REQUIRED (HIGH, con expiresAt)freights/[id]/delivery · MF-06mapeado
/account/paymentsPAYMENT_RECEIVED/CONFIRMED · QUOTATION_CREATED/APPROVED (quotations)payments · MF-07mapeado
/account/service-quotes/{id}/documentQUOTATION_CREATED (service-quotes, HIGH)service-quotes/[id]/document · MF-08mapeado
/account/documents/view?url&titleDELIVERY_COMPLETED · STORAGE_WITHDRAWAL_COMPLETED (con PDF)visor de documentos · MF-04mapeado
/catalog/{id}NEW_CATALOG (a todos, con groupKey)catalog/[id] · MF-12mapeado
/accountPROFILE_COMPLETED (HIGH)Iniciomapeado
/account/storage/{id}STORAGE_RESERVATION/MODIFICATION/WITHDRAWAL de clientelista de notificacionesfallback · MF-16
/account/mogos-idWALLET_PASS_READY · STORAGE_WITHDRAWAL_SCHEDULED (retiro listo)lista de notificacionesfallback · MF-16
/complete-profile?source=oauthACCOUNT_CREATED (jwt.strategy, JIT)(auth)/complete-profile · MF-02retarget · existe

El fallback de lo que siga sin pantalla registra NOTIFICATION_ROUTE_UNMAPPED en PostHog — cuando MF-16 exista, esa métrica dice cuántos taps esperaban storage o Mogos ID, y esos dos taps se retargetean. /complete-profile ya no entra en ese fallback. Staff aparte: /orders/{id}, /storage/{id}, /inbox?conversation= y /finanzas/fuentes jamás se despachan a la app cliente. Deuda web anotada: el Zod de client/admin tiene 30 de 31 tipos — una fila WALLET_PASS_READY rompe el feed web entero; el de móvil ya tiene los 31.

06La fase backend, especificada

DeviceToken · POST/DELETE /users/me/device-tokens · PushDispatchService · expo-server-sdk · DECISIONS #7

Push es greenfield: cero paquetes, cero modelo, cero endpoints. La fase backend-feature de MF-10 lo construye entero — aquí se especifica y no se implementa. La clave de la arquitectura es el choke point: los 19 emisores ya pasan (casi todos) por NotificationsService.create/createMany; enganchar el dispatch ahí enciende el push para todos a la vez, sin tocar emisor por emisor.

  1. Modelo DeviceToken + migración

    id · userId (FK, onDelete Cascade) · token @unique · platform (ios|android) · appVersion · locale · createdAt · lastSeenAt, índice por userId. El locale viaja aquí porque el idioma del usuario no vive en ningún otro lado de la base.

  2. Endpoints self

    POST /users/me/device-tokens — upsert por token: re-registrar renueva lastSeenAt, appVersion y locale (cambio de idioma incluido). DELETE — borra el token del body (logout y switch global). Contrato de red #13: Bearer + X-Mogos-Client-Scope: self.

  3. PushDispatchService en el choke point

    Tras create/createMany: junta los tokens del userId, excluye lo que no es de cliente (metadata.audience === 'STAFF' + la lista de tipos/destinos staff — orders:1528 y los SYSTEM_ALERT del inbox no llevan sello y hoy se le colarían a un admin-cliente), respeta priority (canal Android + prioridad expo), groupKey (colapso), expiresAt (TTL — vencida no se envía), y arma el payload {notificationId, actionUrl, type, badge} con badge = unread-count del usuario.

  4. Los dos emisores rebeldes entran al redil

    freights.service.ts:3344 y service-quote-document.service.ts:276 escriben con Prisma directo: se redirigen por NotificationsService.create — si no, la nota de entrega y la cotización de servicio (los dos HIGH) jamás sonarían. createMany gana una variante que devuelve ids para poder adjuntar notificationId.

  5. El idioma del push

    Decisión cerrada: el texto sale de un catálogo compartido es/en/zh, elegido por el locale del DeviceToken. No es solo el español guardado en la fila. El catálogo notifications.titles/messages hoy vive en los frontends; esta fase lo porta a un package compartido (o al API).

  6. Limpieza y salud

    Receipts de expo: DeviceNotRegistered borra el token al vuelo. Tokens con lastSeenAt > 90 días se purgan. Tests del módulo con el SDK mockeado: dispatch por prioridad, exclusión de staff, TTL, receipts.

  7. Una sola vía Expo

    Un projectId de EAS y expo-server-sdk. No hay camino nativo APNs + FCM. Fabrizio crea el proyecto Expo, el .p8 (team Q8G2JA9HH8) y el proyecto Firebase/FCM cuando pruebe en un dispositivo. Esos pasos no son trabajo de esta fase. Bloquean la prueba en dispositivo, no el código.

  8. NotificationPreference

    Se construye ahora: cuatro grupos de cliente (envíos, fletes, pagos, avisos) más el switch global. El filtro corre en el mismo choke point del dispatch.

  9. Cron de DEPARTURE_CLOSING_SOON

    Entra en el backend de MF-10. Hoy no hay emisor vivo: el tipo solo aparece en el enum, las etiquetas y notifications.seed.ts; departures.service.ts no notifica y no existe un cron de salidas. Hasta que ese cron exista, departure-day-dialog sigue «Próximamente».

  10. Realtime en el choke point

    Decisión cerrada: broadcast en el mismo choke point — canal notifications-user-{id}, el patrón de sugerencias que MF-13 dejó probado, cero migración. No hay postgres_changes. El evento solo invalida ["notifications"]; los datos siguen saliendo de la API.

07Decisiones cerradas

las seis quedaron cerradas el 3 oct 2026 · Caracas
  • Permiso

    Pre-prompt propio, prompt del sistema virgen

    Una vez, en el primer foreground tras la admisión. «Ahora no» no re-asalta: la re-oferta vive en el banner del centro. Denegado → «Abrir Ajustes», y usePushPermission se re-lee al foreground.

  • Foreground

    Toast Sileo con «Ver», nunca el banner del sistema

    setNotificationHandler apaga el alert; el toast expandido de MF-01 presenta el título localizado + acción. La fachada lib/toast.ts se amplía para aceptar description/action/icon.

  • Deep link

    El fallback es la lista, no el Inicio

    Lo que siga sin pantalla —hoy solo storage y Mogos ID— cae en /account/notifications (push) o en un toast en sitio (tap dentro del centro). El evento de PostHog se conserva. /complete-profile no usa este fallback: se retargetea al flujo que ya existe.

  • Badge

    Doble vía: el dispatch manda, la app reconcilia

    badge en cada payload + setBadgeCountAsync colgado de la query unread-count. Logout → 0 y DELETE del token antes de signOut (después no hay JWT).

  • Android

    Cinco canales = cinco categorías web

    El mismo mapa de notification-icons.ts que pinta los plates decide el canal. Nombres localizados; HIGH/URGENT suben la importancia. iOS: switch global en la app, porque canales no hay.

  • Centro

    MF-03 no se reabre: cambia el motor

    Invalidación por push + canal por usuario + refetch al foreground; el poll queda a 120 s de red de seguridad. Se agregan pull-to-refresh y pie de reintento del stack compartido (Ley #28).

  • Cerrada · 1

    Una vía Expo, un projectId

    Push por expo-server-sdk con un solo extra.eas.projectId. No hay camino nativo APNs + FCM. Fabrizio crea el proyecto Expo, el .p8 del team Q8G2JA9HH8 y el proyecto Firebase/FCM cuando pruebe en un dispositivo. Esos pasos no son trabajo de esta fase. La prueba en dispositivo queda bloqueada en eso; el código, no.

  • Cerrada · 2

    NotificationPreference entra ahora

    Cuatro grupos de cliente — envíos, fletes, pagos, avisos — más el switch global. El filtro está en el choke point del dispatch. El modelo no existe hoy; esta fase lo construye. No queda como deuda.

  • Cerrada · 3

    Realtime: broadcast, sin migración

    Broadcast en el mismo choke point, canal notifications-user-{id}, el patrón que MF-13 probó para sugerencias. Sin migración y sin postgres_changes. El evento solo invalida; los datos siguen viniendo de la API.

  • Cerrada · 4

    Complete-profile existe; storage y Mogos ID, no

    ACCOUNT_CREATED (web /complete-profile, a menudo ?source=oauth) abre el flujo que ya está en apps/mobile/app/(auth)/complete-profile (index, email, phone, document, address, _layout — MF-02). No abre la lista ni el Inicio. Hoy apps/mobile/lib/notifications/navigate.ts trata esa URL como no mapeada y abre el Inicio (comentario en las líneas 12-13); MF-10 la retargetea a la pantalla de auth. PROFILE_COMPLETED sigue en /account (Inicio). /account/storage/{id} y /account/mogos-id (WALLET_PASS_READY, STORAGE_WITHDRAWAL_SCHEDULED) abren la lista hasta MF-16, porque esas pantallas no existen, y entonces se retargetean. Los destinos de cliente ya mapeados se quedan: envío, flete, firma, pagos, cotización, documentos, catálogo.

  • Cerrada · 5

    El push sale en es, en y zh

    Copy desde un catálogo compartido, elegido por el locale del DeviceToken. No es solo español.

  • Cerrada · 6

    DEPARTURE_CLOSING_SOON entra en el backend

    El cron del recordatorio del día de salida es parte del backend de MF-10. No hay emisor hoy: confirmado en apps/api — el tipo vive en el enum, las etiquetas y el seed notifications.seed.ts; departures.service.ts no emite y no hay cron de salidas. Hasta que ese cron exista, departure-day-dialog sigue «Próximamente».

  • Motion

    Solo lo que ya existe

    Toast drop 640 ease-spring (Sileo) · wiggle de campana 640 (MF-03) · fila nueva 260 ease-out + anillo 2,4 s · switch 160. Nada nuevo que mantener; reduced motion: estados finales directos.

  • Testing

    DoD en dispositivo = Fabrizio

    Aceptar permisos → cambiar un estado desde el admin web → push en iPhone y Android con ícono y badge → tap al detalle correcto → toast y campana sin refrescar con la app abierta → logout corta el push. Dev build nuevo obligatorio: expo-notifications cambia el fingerprint (como expo-video en MF-13). C9: expo export --platform web + visual-qa 390×844 contra estas maquetas; el push real solo se prueba en dispositivo.