01El permiso, pedido con valor
usePushPermission · pre-prompt sheet · Notifications.requestPermissionsAsync · Linking.openSettingsEl 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.
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-defde 48. Copy nuevo con clavesmobile.pushPrompt.*es/en/zh — no hay paridad que respetar: la web no tiene push.«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.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 tintawarning-deep(AA), nunca rojo — no es un error, es una elección reversible.El estado vive en un solo hook
usePushPermissionleegetPermissionsAsyncal foreground (AppState): si el usuario activó el permiso en Ajustes, el banner desaparece y el token se registra solo, sin pedirle nada.canAskAgain=falsees lo que decide banner de Ajustes vs pre-prompt.
02La push llega
setNotificationHandler · toast Sileo (MF-01) · openNotificationTarget · setBadgeCountAsync · canales AndroidCon 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.
El toast es el de MF-01, expandido
La píldora Sileo con
descriptionse 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 eldropde 640 ms en--ease-spring, se va sola a los 4 s. La fachadalib/toast.tsse amplía: hoytoast.infono acepta opciones — MF-10 le enseña{description, action, icon}.El texto llega ya localizado — y se re-localiza
El payload trae
{notificationId, actionUrl, type}y el texto del idioma delDeviceToken. Al presentarlo, la app lo pasa porlocalizeNotification(el sobremetadata.i18nya portado enlib/notifications/localize.ts): si el usuario cambió de idioma después del registro, el toast sale bien igual.«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 elnotificationId, navegar. Arranque en frío sin sesión: el stash deattempted-hrefde MF-03 guarda el destino y lo retoma tras el login.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.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 consetBadgeCountAsync(count)cada vez que la queryunread-countcambia: foreground, marcar leída, marcar todas. Logout → 0.
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.
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 Android | Categoría web | Qué llega | Importancia |
|---|---|---|---|
| Estado de envíos | status | ORDER_STATUS_CHANGED (creada, asignada, aprobada, rechazada, estado) | Default · HIGH sube a Max |
| Fletes y entregas | shipping | FREIGHT_STATUS, TRANSIT_STOP/DEPARTED/SKIPPED, DELIVERY_COMPLETED, SIGNATURE_REQUIRED | Default · HIGH/URGENT → Max + sonido |
| Pagos | payment | PAYMENT_RECEIVED/CONFIRMED, QUOTATION_CREATED/APPROVED | Default |
| Almacenaje | storage | STORAGE_* de cliente (reserva, modificación, retiro) | Default |
| Avisos de Mogos | system | NEW_CATALOG, ACCOUNT_CREATED, PROFILE_COMPLETED, WALLET_PASS_READY | Low · 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} · AppStateEl 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.
La fila que acaba de llegar respira una vez
Entra con slide + fade de 260 ms en
--ease-outy 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.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 →refetchQueriesdel contador y la primera página. El interval baja a 120 s como último recurso,refetchIntervalInBackgroundsigue en false.Lo que el centro gana de la pila compartida
Pull-to-refresh nativo y el pie de reintento de
InfiniteListFootercuando una página falla — los dos existían encomponents/listsy el centro de MF-03 no los usaba (deuda de Ley #28, anotada; la adopción completa del stack queda para consolidate).Tocar una fila sin destino no expulsa
En el centro ya estás en la lista: una fila cuyo
actionUrlno 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.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-tokensUna 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.
Cuenta
Accesos
Ayuda
Preferencias
El switch de mobile-ui, primera vez en una lista
SwitchconcontrolPosition="trailing"— el arreglo que su propia doc llama «settings-list» y que ningún flujo usaba aún. Fila de 44, plate de 36, trackbrand-700. Apagarlo haceDELETE /users/me/device-tokensy borra el badge; encenderlo re-registra. Optimista, con rollback y toast de error de la casa.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.Cuatro grupos, además del switch global
Decisión cerrada:
NotificationPreferencese 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.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.tsLa 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 emite | Aterriza en | Estado |
|---|---|---|---|
/account/my-shipments/{id} | ORDER_STATUS_CHANGED (creada, asignada, estado, aprobada, rechazada) | my-shipments/[id] · MF-04 | mapeado |
/account/freights/{id} | FREIGHT_STATUS_CHANGED · FREIGHT_TRANSIT_STOP/DEPARTED/SKIPPED | freights/[id] · MF-06 | mapeado |
/account/freights/{id}/delivery?token= | SIGNATURE_REQUIRED (HIGH, con expiresAt) | freights/[id]/delivery · MF-06 | mapeado |
/account/payments | PAYMENT_RECEIVED/CONFIRMED · QUOTATION_CREATED/APPROVED (quotations) | payments · MF-07 | mapeado |
/account/service-quotes/{id}/document | QUOTATION_CREATED (service-quotes, HIGH) | service-quotes/[id]/document · MF-08 | mapeado |
/account/documents/view?url&title | DELIVERY_COMPLETED · STORAGE_WITHDRAWAL_COMPLETED (con PDF) | visor de documentos · MF-04 | mapeado |
/catalog/{id} | NEW_CATALOG (a todos, con groupKey) | catalog/[id] · MF-12 | mapeado |
/account | PROFILE_COMPLETED (HIGH) | Inicio | mapeado |
/account/storage/{id} | STORAGE_RESERVATION/MODIFICATION/WITHDRAWAL de cliente | lista de notificaciones | fallback · MF-16 |
/account/mogos-id | WALLET_PASS_READY · STORAGE_WITHDRAWAL_SCHEDULED (retiro listo) | lista de notificaciones | fallback · MF-16 |
/complete-profile?source=oauth | ACCOUNT_CREATED (jwt.strategy, JIT) | (auth)/complete-profile · MF-02 | retarget · 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 #7Push 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.
Modelo
DeviceToken+ migraciónid · userId (FK, onDelete Cascade) · token @unique · platform (ios|android) · appVersion · locale · createdAt · lastSeenAt, índice poruserId. Ellocaleviaja aquí porque el idioma del usuario no vive en ningún otro lado de la base.Endpoints self
POST /users/me/device-tokens— upsert por token: re-registrar renuevalastSeenAt,appVersionylocale(cambio de idioma incluido).DELETE— borra el token del body (logout y switch global). Contrato de red #13: Bearer +X-Mogos-Client-Scope: self.PushDispatchServiceen el choke pointTras
create/createMany: junta los tokens deluserId, excluye lo que no es de cliente (metadata.audience === 'STAFF'+ la lista de tipos/destinos staff —orders:1528y losSYSTEM_ALERTdel inbox no llevan sello y hoy se le colarían a un admin-cliente), respetapriority(canal Android + prioridad expo),groupKey(colapso),expiresAt(TTL — vencida no se envía), y arma el payload{notificationId, actionUrl, type, badge}conbadge= unread-count del usuario.Los dos emisores rebeldes entran al redil
freights.service.ts:3344yservice-quote-document.service.ts:276escriben con Prisma directo: se redirigen porNotificationsService.create— si no, la nota de entrega y la cotización de servicio (los dos HIGH) jamás sonarían.createManygana una variante que devuelve ids para poder adjuntarnotificationId.El idioma del push
Decisión cerrada: el texto sale de un catálogo compartido es/en/zh, elegido por el
localedel DeviceToken. No es solo el español guardado en la fila. El catálogonotifications.titles/messageshoy vive en los frontends; esta fase lo porta a un package compartido (o al API).Limpieza y salud
Receipts de expo:
DeviceNotRegisteredborra el token al vuelo. Tokens conlastSeenAt> 90 días se purgan. Tests del módulo con el SDK mockeado: dispatch por prioridad, exclusión de staff, TTL, receipts.Una sola vía Expo
Un
projectIdde EAS yexpo-server-sdk. No hay camino nativo APNs + FCM. Fabrizio crea el proyecto Expo, el.p8(teamQ8G2JA9HH8) 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.NotificationPreferenceSe 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.
Cron de
DEPARTURE_CLOSING_SOONEntra 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.tsno notifica y no existe un cron de salidas. Hasta que ese cron exista,departure-day-dialogsigue «Próximamente».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 haypostgres_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
usePushPermissionse re-lee al foreground. - Foreground
Toast Sileo con «Ver», nunca el banner del sistema
setNotificationHandlerapaga el alert; el toast expandido de MF-01 presenta el título localizado + acción. La fachadalib/toast.tsse 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-profileno usa este fallback: se retargetea al flujo que ya existe. - Badge
Doble vía: el dispatch manda, la app reconcilia
badgeen cada payload +setBadgeCountAsynccolgado de la queryunread-count. Logout → 0 y DELETE del token antes designOut(después no hay JWT). - Android
Cinco canales = cinco categorías web
El mismo mapa de
notification-icons.tsque 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-sdkcon un soloextra.eas.projectId. No hay camino nativo APNs + FCM. Fabrizio crea el proyecto Expo, el.p8del teamQ8G2JA9HH8y 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 sinpostgres_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á enapps/mobile/app/(auth)/complete-profile(index, email, phone, document, address,_layout— MF-02). No abre la lista ni el Inicio. Hoyapps/mobile/lib/notifications/navigate.tstrata 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_COMPLETEDsigue 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
localedel 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 seednotifications.seed.ts;departures.service.tsno emite y no hay cron de salidas. Hasta que ese cron exista,departure-day-dialogsigue «Próximamente». - Motion
Solo lo que ya existe
Toast drop 640
ease-spring(Sileo) · wiggle de campana 640 (MF-03) · fila nueva 260ease-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-notificationscambia 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.