From 602241e2e6f280204d2a61324b0a3140b15c1eab Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Mon, 1 Jun 2026 11:54:39 +0100 Subject: [PATCH 1/8] feat: update notification-services-controller to support new Segment schema --- .../CHANGELOG.md | 16 ++++ .../NotificationServicesController.ts | 6 +- .../NotificationServicesController/index.ts | 1 + .../utils/get-notification-subtype.test.ts | 31 +++++++ .../utils/get-notification-subtype.ts | 33 +++++++ .../NotificationServicesPushController.ts | 7 +- .../types/index.ts | 1 + .../types/push-analytics.ts | 32 +++++++ .../web/push-utils.test.ts | 64 +++++++++---- .../web/push-utils.ts | 91 ++++++++++--------- 10 files changed, 214 insertions(+), 68 deletions(-) create mode 100644 packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts create mode 100644 packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts create mode 100644 packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 5aa4c3f76c..9837ea07cd 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `profile_id`, `chain_id`, `deeplink`). +- Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. + +### Changed + +- **BREAKING:** The `NotificationServicesPushController:onNewNotifications` and `NotificationServicesPushController:pushNotificationClicked` messenger events now carry `PushAnalyticsPayload` instead of `INotification`. + - The push payload no longer carries the full notification body; clients construct their analytics events directly from the first-class fields. + - The `onReceivedHandler` / `onClickHandler` callbacks passed to `createSubscribeToPushNotifications` now receive a `PushAnalyticsPayload` instead of an `INotification`. +- On push receive, the controller now re-fetches the notifications list from the API rather than inserting the push payload, since the push payload no longer contains the notification body. + +### Removed + +- **BREAKING:** Remove the nested `data["data"]` / `metadata` FCM payload parsing path; push payloads are now read from the top-level FCM fields written by push-services. + ## [24.1.2] ### Changed diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index bbc558c4f3..da0c924632 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -517,9 +517,11 @@ export class NotificationServicesController extends BaseController< subscribe: (): void => { this.messenger.subscribe( 'NotificationServicesPushController:onNewNotifications', - (notification): void => { + (): void => { + // The push payload no longer carries the full notification body, so + // re-fetch the inbox from the API to surface the new notification. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.updateMetamaskNotificationsList(notification); + this.fetchAndUpdateMetamaskNotifications(); }, ); }, diff --git a/packages/notification-services-controller/src/NotificationServicesController/index.ts b/packages/notification-services-controller/src/NotificationServicesController/index.ts index 745d6f723e..64ee6df217 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/index.ts @@ -12,6 +12,7 @@ export * from './constants'; export * as Mocks from './mocks'; export * from '../shared'; export { isVersionInBounds } from './utils/isVersionInBounds'; +export { getNotificationSubtype } from './utils/get-notification-subtype'; export type { NotificationServicesControllerInitAction, diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts new file mode 100644 index 0000000000..c6ab0eb4fa --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts @@ -0,0 +1,31 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema'; +import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements'; +import { createMockNotificationEthReceived } from '../mocks/mock-raw-notifications'; +import { createMockSnapNotification } from '../mocks/mock-snap-notification'; +import { processNotification } from '../processors/process-notifications'; +import { getNotificationSubtype } from './get-notification-subtype'; + +describe('getNotificationSubtype', () => { + it('returns the trigger kind for on-chain notifications', () => { + const notification = processNotification( + createMockNotificationEthReceived(), + ); + expect(getNotificationSubtype(notification)).toBe( + TRIGGER_TYPES.ETH_RECEIVED, + ); + }); + + it('returns the snap subtype for snap notifications', () => { + const notification = processNotification(createMockSnapNotification()); + expect(getNotificationSubtype(notification)).toBe(TRIGGER_TYPES.SNAP); + }); + + it('returns a stable label for feature-announcement notifications', () => { + const notification = processNotification( + createMockFeatureAnnouncementRaw(), + ); + expect(getNotificationSubtype(notification)).toBe( + TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + ); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts new file mode 100644 index 0000000000..4490f927ad --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts @@ -0,0 +1,33 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema'; +import type { INotification } from '../types/notification/notification'; + +/** + * Derives the normalised `notification_subtype` for a processed in-app + * notification. This is the team-owned axis (e.g. `eth_received`) and is + * always derivable from an `INotification`, so every consumer (both clients) + * pulls it from one place rather than recomputing a fallback chain. + * + * - on-chain: the trigger kind (already stored on `type`, e.g. `eth_received`). + * - snap: the snap notification type (`snap`, already snake_case). + * - feature-announcement: a stable label. There is no per-campaign id in the + * current shape, so we use `features_announcement` until/if the announcements + * team backfills per-campaign ids. + * - platform: the server-set `notification_subtype` carried through from the + * platform API. Until that field lands in the API schema, we fall back to + * `type` (`platform`). + * + * @param notification - a processed in-app notification. + * @returns the normalised subtype string. + */ +export function getNotificationSubtype(notification: INotification): string { + switch (notification.type) { + case TRIGGER_TYPES.FEATURES_ANNOUNCEMENT: + return TRIGGER_TYPES.FEATURES_ANNOUNCEMENT; + case TRIGGER_TYPES.SNAP: + return TRIGGER_TYPES.SNAP; + default: + // On-chain notifications store the trigger kind on `type`; platform + // notifications store `platform`. + return notification.type; + } +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts index 44842609fa..278b596ab8 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts @@ -8,7 +8,6 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import log from 'loglevel'; -import type { Types } from '../NotificationServicesController'; import type { NotificationServicesPushControllerMethodActions } from './NotificationServicesPushController-method-action-types'; import type { ENV } from './services/endpoints'; import type { RegToken } from './services/services'; @@ -18,7 +17,7 @@ import { deactivatePushNotifications, updateLinksAPI, } from './services/services'; -import type { PushNotificationEnv } from './types'; +import type { PushAnalyticsPayload, PushNotificationEnv } from './types'; import type { PushService } from './types/push-service-interface'; const controllerName = 'NotificationServicesPushController'; @@ -59,12 +58,12 @@ export type NotificationServicesPushControllerStateChangeEvent = export type NotificationServicesPushControllerOnNewNotificationEvent = { type: `${typeof controllerName}:onNewNotifications`; - payload: [Types.INotification]; + payload: [PushAnalyticsPayload]; }; export type NotificationServicesPushControllerPushNotificationClickedEvent = { type: `${typeof controllerName}:pushNotificationClicked`; - payload: [Types.INotification]; + payload: [PushAnalyticsPayload]; }; export type Events = diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts index 693b8999cb..0a00a055c6 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts @@ -1,2 +1,3 @@ export type * from './firebase'; +export type * from './push-analytics'; export type * from './push-service-interface'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts new file mode 100644 index 0000000000..dfcfb34948 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts @@ -0,0 +1,32 @@ +// First-class analytics fields mirror the snake_case keys written into the FCM +// payload by push-services and the Segment schema, so this type intentionally +// uses snake_case rather than the camelCase domain convention. +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * First-class push notification fields carried by the + * `NotificationServicesPushController` messenger events + * (`onNewNotifications` and `pushNotificationClicked`). + * + * These are read directly from the top-level FCM payload keys written by + * push-services, so clients construct their Segment events from this payload + * directly — no nested fallback chains, no JSON-parsing of a `metadata` blob. + * + * `profile_id` is belt-and-braces: clients should still prefer their own + * `AuthenticationController` reading for the canonical "current user", but the + * FCM-supplied value lets us cross-check server vs. client and survives + * auth-controller-not-yet-ready races. + */ +export type PushAnalyticsPayload = { + notification_id: string; + /** Free-form snake_case label set by the producer. */ + notification_type: string; + /** Team-owned, open-ended (e.g. `eth_received`). */ + notification_subtype: string; + /** Server-side fallback; clients prefer their own AuthController source. */ + profile_id?: string; + /** Only present when the notification has a chain context. */ + chain_id?: number; + /** Platform notifications only; the CTA link to route to on tap. */ + deeplink?: string; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts index 6a09cbccc0..df1f459661 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts @@ -2,9 +2,8 @@ import * as FirebaseAppModule from 'firebase/app'; import * as FirebaseMessagingModule from 'firebase/messaging'; import * as FirebaseMessagingSWModule from 'firebase/messaging/sw'; -import { processNotification } from '../../NotificationServicesController'; -import { createMockNotificationEthSent } from '../../NotificationServicesController/mocks/mock-raw-notifications'; import { buildPushPlatformNotificationsControllerMessenger } from '../__fixtures__/mockMessenger'; +import type { PushAnalyticsPayload } from '../types'; import { createRegToken, deleteRegToken, @@ -27,6 +26,26 @@ const mockEnv = { vapidKey: 'test-vapidKey', }; +// The top-level FCM `data` map written by push-services (all values strings). +const mockFcmData = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + profile_id: 'test-profile-id', + chain_id: '1', + deeplink: 'https://example.com/deeplink', +}; + +// The parsed analytics payload the events should publish for `mockFcmData`. +const expectedAnalyticsPayload: PushAnalyticsPayload = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + profile_id: 'test-profile-id', + chain_id: 1, + deeplink: 'https://example.com/deeplink', +}; + const firebaseApp: FirebaseAppModule.FirebaseApp = { name: '', automaticDataCollectionEnabled: false, @@ -332,9 +351,7 @@ describe('createSubscribeToPushNotifications() tests', () => { const firebaseCallback = mocks.mockOnBackgroundMessage.mock .lastCall[1] as FirebaseMessagingModule.NextFn; const payload = { - data: { - data: testData, - }, + data: testData, } as unknown as FirebaseMessagingSWModule.MessagePayload; firebaseCallback(payload); @@ -342,14 +359,16 @@ describe('createSubscribeToPushNotifications() tests', () => { return mocks; } - it('should invoke handler when notifications are received', async () => { - const mocks = await arrangeActNotificationReceived( - JSON.stringify(createMockNotificationEthSent()), - ); + it('should invoke handler with the parsed analytics payload when notifications are received', async () => { + const mocks = await arrangeActNotificationReceived(mockFcmData); - // Assert New Notification Event & Handler Calls - expect(mocks.onNewNotificationsListener).toHaveBeenCalled(); - expect(mocks.mockOnReceivedHandler).toHaveBeenCalled(); + // Assert New Notification Event & Handler Calls carry the analytics payload + expect(mocks.onNewNotificationsListener).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); + expect(mocks.mockOnReceivedHandler).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); // Assert Click Notification Event & Handler Calls expect(mocks.pushNotificationClickedListener).not.toHaveBeenCalled(); @@ -360,7 +379,10 @@ describe('createSubscribeToPushNotifications() tests', () => { { data: undefined }, { data: null }, { data: 'not an object' }, - { data: { id: 'test-id', payload: { data: 'unexpected shape' } } }, + // Missing the required `notification_type` field. + { data: { notification_id: 'test-id' } }, + // Missing the required `notification_id` field. + { data: { notification_type: 'wallet_activity' } }, ]; it.each(invalidNotificationDataPayloadsTests)( @@ -378,23 +400,25 @@ describe('createSubscribeToPushNotifications() tests', () => { await actCreateSubscription(mocks); - const notificationData = processNotification( - createMockNotificationEthSent(), - ); const mockNotificationEvent = new Event( 'notificationclick', ) as NotificationEvent; Object.assign(mockNotificationEvent, { - notification: { data: notificationData }, + notification: { data: expectedAnalyticsPayload }, }); // Act - Testing service worker notification click event // eslint-disable-next-line no-restricted-globals self.dispatchEvent(mockNotificationEvent); - // Assert Click Notification Event & Handler Calls - expect(mocks.pushNotificationClickedListener).toHaveBeenCalled(); - expect(mocks.mockOnClickHandler).toHaveBeenCalled(); + // Assert Click Notification Event & Handler Calls carry the analytics payload + expect(mocks.pushNotificationClickedListener).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); + expect(mocks.mockOnClickHandler).toHaveBeenCalledWith( + expect.any(Event), + expectedAnalyticsPayload, + ); // Assert New Notification Event & Handler Calls expect(mocks.onNewNotificationsListener).not.toHaveBeenCalled(); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts index f3c0257df6..dd81241062 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts @@ -12,13 +12,8 @@ import { import type { Messaging, MessagePayload } from 'firebase/messaging/sw'; import log from 'loglevel'; -import type { Types } from '../../NotificationServicesController'; -import { - isOnChainRawNotification, - safeProcessNotification, -} from '../../NotificationServicesController'; -import { toRawAPINotification } from '../../shared/to-raw-notification'; import type { NotificationServicesPushControllerMessenger } from '../NotificationServicesPushController'; +import type { PushAnalyticsPayload } from '../types'; import type { PushNotificationEnv } from '../types/firebase'; declare const self: ServiceWorkerGlobalScope; @@ -122,7 +117,7 @@ export async function deleteRegToken( */ async function listenToPushNotificationsReceived( env: PushNotificationEnv, - handler?: (notification: Types.INotification) => void | Promise, + handler?: (payload: PushAnalyticsPayload) => void | Promise, ): Promise<(() => void) | null> { const messaging = await getFirebaseMessaging(env); if (!messaging) { @@ -134,31 +129,21 @@ async function listenToPushNotificationsReceived( // eslint-disable-next-line @typescript-eslint/no-misused-promises async (payload: MessagePayload): Promise => { try { - // MessagePayload shapes are not known - // TODO - provide open-api unfied backend/frontend types - // TODO - we will replace the underlying Data payload with the same Notification payload used by mobile - const data: unknown | null = JSON.parse(payload?.data?.data ?? 'null'); + // Read the first-class fields written at the top level of the FCM + // payload by push-services. The raw notification body is no longer + // shipped in the FCM map, so there is no nested `data["data"]` blob or + // `metadata` to parse. + const analyticsPayload = toPushAnalyticsPayload(payload?.data); - if (!data) { + if (!analyticsPayload) { return; } - if (!isOnChainRawNotification(data)) { - return; - } - - const notificationData = toRawAPINotification(data); - const notification = safeProcessNotification(notificationData); - - if (!notification) { - return; - } - - await handler?.(notification); + await handler?.(analyticsPayload); } catch (error) { - // Do Nothing, cannot parse a bad notification - log.error('Unable to send push notification:', { - notification: payload?.data?.data, + // Do Nothing, cannot handle a bad notification + log.error('Unable to handle push notification:', { + notification: payload?.data, error, }); } @@ -169,6 +154,33 @@ async function listenToPushNotificationsReceived( return unsubscribe; } +/** + * Builds the first-class push analytics payload from the top-level FCM `data` + * keys written by push-services. Returns `null` when the required identity + * fields are missing (e.g. a malformed or legacy payload), so callers can + * safely bail out. + * + * @param data - the top-level FCM `data` map (all values are strings). + * @returns the analytics payload, or `null` if required fields are absent. + */ +export function toPushAnalyticsPayload( + data: Record | undefined, +): PushAnalyticsPayload | null { + if (!data?.notification_id || !data?.notification_type) { + return null; + } + + return { + notification_id: data.notification_id, + notification_type: data.notification_type, + notification_subtype: data.notification_subtype ?? '', + // Empty values are omitted by push-services, so treat blanks as absent. + profile_id: data.profile_id || undefined, + chain_id: data.chain_id ? Number(data.chain_id) : undefined, + deeplink: data.deeplink || undefined, + }; +} + /** * Service Worker Listener for when a notification is clicked * @@ -176,11 +188,11 @@ async function listenToPushNotificationsReceived( * @returns unsubscribe handler */ function listenToPushNotificationsClicked( - handler: (e: NotificationEvent, notification: Types.INotification) => void, + handler: (e: NotificationEvent, payload: PushAnalyticsPayload) => void, ): () => void { const clickHandler = (event: NotificationEvent): void => { // Get Data - const data: Types.INotification = event?.notification?.data; + const data: PushAnalyticsPayload = event?.notification?.data; handler(event, data); }; @@ -203,33 +215,28 @@ function listenToPushNotificationsClicked( * @returns a function that can be used by the controller */ export function createSubscribeToPushNotifications(props: { - onReceivedHandler: ( - notification: Types.INotification, - ) => void | Promise; - onClickHandler: ( - e: NotificationEvent, - notification: Types.INotification, - ) => void; + onReceivedHandler: (payload: PushAnalyticsPayload) => void | Promise; + onClickHandler: (e: NotificationEvent, payload: PushAnalyticsPayload) => void; messenger: NotificationServicesPushControllerMessenger; }): (env: PushNotificationEnv) => Promise<() => void> { return async function (env: PushNotificationEnv): Promise<() => void> { const onBackgroundMessageSub = await listenToPushNotificationsReceived( env, - async (notification): Promise => { + async (analyticsPayload): Promise => { props.messenger.publish( 'NotificationServicesPushController:onNewNotifications', - notification, + analyticsPayload, ); - await props.onReceivedHandler(notification); + await props.onReceivedHandler(analyticsPayload); }, ); const onClickSub = listenToPushNotificationsClicked( - (event, notification): void => { + (event, analyticsPayload): void => { props.messenger.publish( 'NotificationServicesPushController:pushNotificationClicked', - notification, + analyticsPayload, ); - props.onClickHandler(event, notification); + props.onClickHandler(event, analyticsPayload); }, ); From b765c4a518b94b5f6eb7ac32fcbbdab712895d22 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Mon, 1 Jun 2026 13:04:10 +0100 Subject: [PATCH 2/8] fix: getNotificationSubtype --- .../utils/get-notification-subtype.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts index 4490f927ad..a640650c1b 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts @@ -7,14 +7,14 @@ import type { INotification } from '../types/notification/notification'; * always derivable from an `INotification`, so every consumer (both clients) * pulls it from one place rather than recomputing a fallback chain. * - * - on-chain: the trigger kind (already stored on `type`, e.g. `eth_received`). + * - on-chain: the trigger kind (`payload.data.kind`, e.g. `eth_received`). * - snap: the snap notification type (`snap`, already snake_case). - * - feature-announcement: a stable label. There is no per-campaign id in the - * current shape, so we use `features_announcement` until/if the announcements - * team backfills per-campaign ids. + * - feature-announcement: §5.3 calls for a stable per-campaign id, pending + * confirmation from the announcements team that one exists. Until confirmed, + * we use the `features_announcement` label as the single value. * - platform: the server-set `notification_subtype` carried through from the - * platform API. Until that field lands in the API schema, we fall back to - * `type` (`platform`). + * platform API (§4.2). That field is not in the generated `schema.ts` yet, so + * until it is regenerated we fall back to `type` (`platform`). * * @param notification - a processed in-app notification. * @returns the normalised subtype string. @@ -22,12 +22,22 @@ import type { INotification } from '../types/notification/notification'; export function getNotificationSubtype(notification: INotification): string { switch (notification.type) { case TRIGGER_TYPES.FEATURES_ANNOUNCEMENT: + // §5.3 calls for a stable per-campaign id here, pending confirmation from + // the announcements team that one exists. Until confirmed, use the + // `features_announcement` label as the single value. return TRIGGER_TYPES.FEATURES_ANNOUNCEMENT; case TRIGGER_TYPES.SNAP: + // Snap notification type, already snake_case in the existing shape. return TRIGGER_TYPES.SNAP; default: - // On-chain notifications store the trigger kind on `type`; platform - // notifications store `platform`. + // On-chain: the trigger kind (e.g. `eth_received`). + if (notification.notification_type === 'on-chain') { + return notification.payload.data.kind; + } + // Platform: §5.3 wants the server-set `notification_subtype` from the + // platform API (§4.2). That field is not in the generated `schema.ts` + // yet, so until it is regenerated we fall back to `type` (`platform`). + // TODO(§4.2): return notification.notification_subtype once available. return notification.type; } } From 16656ed724ce07506114261fdac073ab26a943d0 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Mon, 1 Jun 2026 13:09:09 +0100 Subject: [PATCH 3/8] chore: update comments after re-generating types --- .../utils/get-notification-subtype.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts index a640650c1b..998cb98bd4 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts @@ -12,9 +12,10 @@ import type { INotification } from '../types/notification/notification'; * - feature-announcement: §5.3 calls for a stable per-campaign id, pending * confirmation from the announcements team that one exists. Until confirmed, * we use the `features_announcement` label as the single value. - * - platform: the server-set `notification_subtype` carried through from the - * platform API (§4.2). That field is not in the generated `schema.ts` yet, so - * until it is regenerated we fall back to `type` (`platform`). + * - platform: the server-set `notification_subtype`. The backend stores it + * (notify-notification-services §4.3), but the `/api/v3/notifications` inbox + * response does not expose it yet, so it is absent from the generated + * `schema.ts` and we fall back to `type` (`platform`). * * @param notification - a processed in-app notification. * @returns the normalised subtype string. @@ -34,10 +35,12 @@ export function getNotificationSubtype(notification: INotification): string { if (notification.notification_type === 'on-chain') { return notification.payload.data.kind; } - // Platform: §5.3 wants the server-set `notification_subtype` from the - // platform API (§4.2). That field is not in the generated `schema.ts` - // yet, so until it is regenerated we fall back to `type` (`platform`). - // TODO(§4.2): return notification.notification_subtype once available. + // Platform: §5.3 wants the server-set `notification_subtype`. It is + // stored backend-side (§4.3) but not returned on the `/api/v3/notifications` + // inbox response, so it is absent from `schema.ts`. Fall back to `type` + // (`platform`) until the inbox API exposes it. + // TODO: return notification.notification_subtype once the inbox API + // response includes it (needs a notify-notification-services change). return notification.type; } } From e3aabcef04df66e5ae3f0629b5d88a08c2d963c2 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Mon, 1 Jun 2026 13:18:16 +0100 Subject: [PATCH 4/8] test: add missing case to subtype helper suite --- .../utils/get-notification-subtype.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts index c6ab0eb4fa..0fd56d333d 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts @@ -1,6 +1,9 @@ import { TRIGGER_TYPES } from '../constants/notification-schema'; import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements'; -import { createMockNotificationEthReceived } from '../mocks/mock-raw-notifications'; +import { + createMockNotificationEthReceived, + createMockPlatformNotification, +} from '../mocks/mock-raw-notifications'; import { createMockSnapNotification } from '../mocks/mock-snap-notification'; import { processNotification } from '../processors/process-notifications'; import { getNotificationSubtype } from './get-notification-subtype'; @@ -15,6 +18,11 @@ describe('getNotificationSubtype', () => { ); }); + it('falls back to the type label for platform notifications', () => { + const notification = processNotification(createMockPlatformNotification()); + expect(getNotificationSubtype(notification)).toBe(TRIGGER_TYPES.PLATFORM); + }); + it('returns the snap subtype for snap notifications', () => { const notification = processNotification(createMockSnapNotification()); expect(getNotificationSubtype(notification)).toBe(TRIGGER_TYPES.SNAP); From 5ec0e5b59ae8f8858c6985795061fb20ea05c7c5 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Mon, 1 Jun 2026 14:56:45 +0100 Subject: [PATCH 5/8] chore: fix CI changelog check --- packages/notification-services-controller/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 9837ea07cd..4077af8e76 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -9,19 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `profile_id`, `chain_id`, `deeplink`). -- Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. +- Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `profile_id`, `chain_id`, `deeplink`). ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. ([#8944](https://github.com/MetaMask/core/pull/8944)) ### Changed -- **BREAKING:** The `NotificationServicesPushController:onNewNotifications` and `NotificationServicesPushController:pushNotificationClicked` messenger events now carry `PushAnalyticsPayload` instead of `INotification`. +- **BREAKING:** The `NotificationServicesPushController:onNewNotifications` and `NotificationServicesPushController:pushNotificationClicked` messenger events now carry `PushAnalyticsPayload` instead of `INotification`. ([#8944](https://github.com/MetaMask/core/pull/8944)) - The push payload no longer carries the full notification body; clients construct their analytics events directly from the first-class fields. - The `onReceivedHandler` / `onClickHandler` callbacks passed to `createSubscribeToPushNotifications` now receive a `PushAnalyticsPayload` instead of an `INotification`. -- On push receive, the controller now re-fetches the notifications list from the API rather than inserting the push payload, since the push payload no longer contains the notification body. +- On push receive, the controller now re-fetches the notifications list from the API rather than inserting the push payload, since the push payload no longer contains the notification body. ([#8944](https://github.com/MetaMask/core/pull/8944)) ### Removed -- **BREAKING:** Remove the nested `data["data"]` / `metadata` FCM payload parsing path; push payloads are now read from the top-level FCM fields written by push-services. +- **BREAKING:** Remove the nested `data["data"]` / `metadata` FCM payload parsing path; push payloads are now read from the top-level FCM fields written by push-services. ([#8944](https://github.com/MetaMask/core/pull/8944)) ## [24.1.2] From d94df657eb8f5a0148bde08853ec557b07f1e722 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Tue, 2 Jun 2026 12:18:05 +0100 Subject: [PATCH 6/8] feat: add subtype to base notification --- .../NotificationServicesController.test.ts | 4 ++++ .../processors/process-api-notifications.ts | 3 +++ .../processors/process-feature-announcement.ts | 3 +++ .../processors/process-snap-notifications.ts | 3 +++ .../types/notification/notification.ts | 2 ++ .../utils/get-notification-subtype.ts | 8 +++++--- 6 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 52514d76ce..ad5b99ce8c 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -1236,6 +1236,8 @@ describe('NotificationServicesController', () => { expect(filteredNotifications).toStrictEqual([ { type: TRIGGER_TYPES.SNAP, + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: TRIGGER_TYPES.SNAP, id: expect.any(String), createdAt: expect.any(String), isRead: false, @@ -1605,6 +1607,8 @@ describe('NotificationServicesController', () => { expect(controller.state.metamaskNotificationsList).toStrictEqual([ { type: TRIGGER_TYPES.SNAP, + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: TRIGGER_TYPES.SNAP, id: expect.any(String), createdAt: expect.any(String), readDate: null, diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts index dac4f2ed38..2254bfb4a0 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts @@ -1,5 +1,6 @@ import type { NormalisedAPINotification } from '../types/notification-api/notification-api'; import type { INotification } from '../types/notification/notification'; +import { getNotificationSubtype } from '../utils/get-notification-subtype'; import { shouldAutoExpire } from '../utils/should-auto-expire'; /** @@ -17,6 +18,8 @@ export function processAPINotifications( return { ...notification, id: notification.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: getNotificationSubtype(notification), createdAt: createdAtDate.toISOString(), isRead: expired || !notification.unread, }; diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts index 7033e0451f..3725718976 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts @@ -1,5 +1,6 @@ import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement'; import type { INotification } from '../types/notification/notification'; +import { getNotificationSubtype } from '../utils/get-notification-subtype'; import { shouldAutoExpire } from '../utils/should-auto-expire'; /** @@ -32,6 +33,8 @@ export function processFeatureAnnouncement( return { type: notification.type, id: notification.data.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: getNotificationSubtype(notification), createdAt: new Date(notification.createdAt).toISOString(), data: notification.data, isRead: false, diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts index 17227edaa6..b86eed9c71 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts @@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid'; import type { INotification } from '../types'; import type { RawSnapNotification } from '../types/snaps'; +import { getNotificationSubtype } from '../utils/get-notification-subtype'; /** * Processes a snap notification into a normalized shape. @@ -15,6 +16,8 @@ export const processSnapNotification = ( const { data, type, readDate } = snapNotification; return { id: uuid(), + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: getNotificationSubtype(snapNotification), readDate, createdAt: new Date().toISOString(), isRead: false, diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts index aa7102aa27..4e4add5133 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts @@ -5,6 +5,7 @@ import type { Compute } from '../type-utils'; export type BaseNotification = { id: string; + notification_subtype: string; createdAt: string; isRead: boolean; }; @@ -21,6 +22,7 @@ export type RawNotificationUnion = * - `data` field (declared in the Raw shapes) */ export type INotification = Compute< + // eslint-disable-next-line @typescript-eslint/naming-convention | (FeatureAnnouncementRawNotification & BaseNotification) | (NormalisedAPINotification & BaseNotification) | (RawSnapNotification & BaseNotification & { readDate?: string | null }) diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts index 998cb98bd4..86340d71c1 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts @@ -1,5 +1,5 @@ import { TRIGGER_TYPES } from '../constants/notification-schema'; -import type { INotification } from '../types/notification/notification'; +import type { RawNotificationUnion } from '../types/notification/notification'; /** * Derives the normalised `notification_subtype` for a processed in-app @@ -17,10 +17,12 @@ import type { INotification } from '../types/notification/notification'; * response does not expose it yet, so it is absent from the generated * `schema.ts` and we fall back to `type` (`platform`). * - * @param notification - a processed in-app notification. + * @param notification - a raw or processed notification. * @returns the normalised subtype string. */ -export function getNotificationSubtype(notification: INotification): string { +export function getNotificationSubtype( + notification: RawNotificationUnion, +): string { switch (notification.type) { case TRIGGER_TYPES.FEATURES_ANNOUNCEMENT: // §5.3 calls for a stable per-campaign id here, pending confirmation from From e68d492840f8be9bffb2d1919f2999af0cae7e2d Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Tue, 2 Jun 2026 23:50:21 +0100 Subject: [PATCH 7/8] chore: cleanup --- .../NotificationServicesController.test.ts | 2 -- .../processors/process-api-notifications.ts | 1 - .../process-feature-announcement.ts | 1 - .../processors/process-snap-notifications.ts | 1 - .../types/notification/notification.ts | 2 +- .../types/push-analytics.ts | 22 +++++-------------- .../web/push-utils.test.ts | 2 -- .../web/push-utils.ts | 5 ----- 8 files changed, 7 insertions(+), 29 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index ad5b99ce8c..6560199f0c 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -1236,7 +1236,6 @@ describe('NotificationServicesController', () => { expect(filteredNotifications).toStrictEqual([ { type: TRIGGER_TYPES.SNAP, - // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: TRIGGER_TYPES.SNAP, id: expect.any(String), createdAt: expect.any(String), @@ -1607,7 +1606,6 @@ describe('NotificationServicesController', () => { expect(controller.state.metamaskNotificationsList).toStrictEqual([ { type: TRIGGER_TYPES.SNAP, - // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: TRIGGER_TYPES.SNAP, id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts index 2254bfb4a0..d46a40b303 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts @@ -18,7 +18,6 @@ export function processAPINotifications( return { ...notification, id: notification.id, - // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: getNotificationSubtype(notification), createdAt: createdAtDate.toISOString(), isRead: expired || !notification.unread, diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts index 3725718976..5457e15f01 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts @@ -33,7 +33,6 @@ export function processFeatureAnnouncement( return { type: notification.type, id: notification.data.id, - // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: getNotificationSubtype(notification), createdAt: new Date(notification.createdAt).toISOString(), data: notification.data, diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts index b86eed9c71..4aba3c65e2 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts @@ -16,7 +16,6 @@ export const processSnapNotification = ( const { data, type, readDate } = snapNotification; return { id: uuid(), - // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: getNotificationSubtype(snapNotification), readDate, createdAt: new Date().toISOString(), diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts index 4e4add5133..66f2700a28 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts @@ -5,6 +5,7 @@ import type { Compute } from '../type-utils'; export type BaseNotification = { id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention notification_subtype: string; createdAt: string; isRead: boolean; @@ -22,7 +23,6 @@ export type RawNotificationUnion = * - `data` field (declared in the Raw shapes) */ export type INotification = Compute< - // eslint-disable-next-line @typescript-eslint/naming-convention | (FeatureAnnouncementRawNotification & BaseNotification) | (NormalisedAPINotification & BaseNotification) | (RawSnapNotification & BaseNotification & { readDate?: string | null }) diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts index dfcfb34948..cccfc9bd0a 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts @@ -1,21 +1,11 @@ -// First-class analytics fields mirror the snake_case keys written into the FCM -// payload by push-services and the Segment schema, so this type intentionally -// uses snake_case rather than the camelCase domain convention. +// snake_case mirrors the FCM payload and Segment schema keys /* eslint-disable @typescript-eslint/naming-convention */ /** - * First-class push notification fields carried by the - * `NotificationServicesPushController` messenger events - * (`onNewNotifications` and `pushNotificationClicked`). - * - * These are read directly from the top-level FCM payload keys written by - * push-services, so clients construct their Segment events from this payload - * directly — no nested fallback chains, no JSON-parsing of a `metadata` blob. - * - * `profile_id` is belt-and-braces: clients should still prefer their own - * `AuthenticationController` reading for the canonical "current user", but the - * FCM-supplied value lets us cross-check server vs. client and survives - * auth-controller-not-yet-ready races. + * Analytics fields carried by the `NotificationServicesPushController` messenger + * events (`onNewNotifications`, `pushNotificationClicked`). Read directly from + * top-level FCM payload keys, so clients build Segment events without fallback + * chains or parsing a `metadata` blob. */ export type PushAnalyticsPayload = { notification_id: string; @@ -23,7 +13,7 @@ export type PushAnalyticsPayload = { notification_type: string; /** Team-owned, open-ended (e.g. `eth_received`). */ notification_subtype: string; - /** Server-side fallback; clients prefer their own AuthController source. */ + /** Server-side cross-check; clients prefer their own AuthController source. */ profile_id?: string; /** Only present when the notification has a chain context. */ chain_id?: number; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts index df1f459661..632762dd63 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts @@ -26,7 +26,6 @@ const mockEnv = { vapidKey: 'test-vapidKey', }; -// The top-level FCM `data` map written by push-services (all values strings). const mockFcmData = { notification_id: 'test-notification-id', notification_type: 'wallet_activity', @@ -36,7 +35,6 @@ const mockFcmData = { deeplink: 'https://example.com/deeplink', }; -// The parsed analytics payload the events should publish for `mockFcmData`. const expectedAnalyticsPayload: PushAnalyticsPayload = { notification_id: 'test-notification-id', notification_type: 'wallet_activity', diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts index dd81241062..6672c71653 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts @@ -129,10 +129,6 @@ async function listenToPushNotificationsReceived( // eslint-disable-next-line @typescript-eslint/no-misused-promises async (payload: MessagePayload): Promise => { try { - // Read the first-class fields written at the top level of the FCM - // payload by push-services. The raw notification body is no longer - // shipped in the FCM map, so there is no nested `data["data"]` blob or - // `metadata` to parse. const analyticsPayload = toPushAnalyticsPayload(payload?.data); if (!analyticsPayload) { @@ -174,7 +170,6 @@ export function toPushAnalyticsPayload( notification_id: data.notification_id, notification_type: data.notification_type, notification_subtype: data.notification_subtype ?? '', - // Empty values are omitted by push-services, so treat blanks as absent. profile_id: data.profile_id || undefined, chain_id: data.chain_id ? Number(data.chain_id) : undefined, deeplink: data.deeplink || undefined, From 81b5f29eb8d09cfb436d21806458431b50a380c5 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Wed, 3 Jun 2026 11:27:17 +0100 Subject: [PATCH 8/8] feat: move toPushAnalytics from web to package utils --- .../CHANGELOG.md | 1 + .../utils/index.ts | 1 + .../utils/to-push-analytics-payload.test.ts | 54 +++++++++++++++++++ .../utils/to-push-analytics-payload.ts | 27 ++++++++++ .../web/push-utils.ts | 27 +--------- 5 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts create mode 100644 packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 4077af8e76..00f3de76d3 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `profile_id`, `chain_id`, `deeplink`). ([#8944](https://github.com/MetaMask/core/pull/8944)) - Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Export `toPushAnalyticsPayload` from `@metamask/notification-services-controller/push-services` so web and mobile clients can parse FCM analytics fields from a shared helper. ([#8944](https://github.com/MetaMask/core/pull/8944)) ### Changed diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts index be95219f07..b715997638 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts @@ -1,2 +1,3 @@ export * from './get-notification-data'; export * from './get-notification-message'; +export * from './to-push-analytics-payload'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts new file mode 100644 index 0000000000..048804b80b --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts @@ -0,0 +1,54 @@ +import type { PushAnalyticsPayload } from '../types'; +import { toPushAnalyticsPayload } from './to-push-analytics-payload'; + +const mockFcmData = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + profile_id: 'test-profile-id', + chain_id: '1', + deeplink: 'https://example.com/deeplink', +}; + +const expectedAnalyticsPayload: PushAnalyticsPayload = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + profile_id: 'test-profile-id', + chain_id: 1, + deeplink: 'https://example.com/deeplink', +}; + +describe('toPushAnalyticsPayload() tests', () => { + it('should build the analytics payload from FCM data', () => { + expect(toPushAnalyticsPayload(mockFcmData)).toStrictEqual( + expectedAnalyticsPayload, + ); + }); + + it('should default notification_subtype to an empty string when absent', () => { + const { notification_subtype: _, ...dataWithoutSubtype } = mockFcmData; + + expect(toPushAnalyticsPayload(dataWithoutSubtype)).toStrictEqual({ + ...expectedAnalyticsPayload, + notification_subtype: '', + }); + }); + + it.each([ + undefined, + null, + 'not an object', + { notification_id: 'test-id' }, + { notification_type: 'wallet_activity' }, + ] as const)( + 'should return null for invalid FCM data payload - %p', + (data) => { + expect( + toPushAnalyticsPayload( + data as unknown as Record | undefined, + ), + ).toBeNull(); + }, + ); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts new file mode 100644 index 0000000000..79ee8c4c96 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts @@ -0,0 +1,27 @@ +import type { PushAnalyticsPayload } from '../types'; + +/** + * Builds the first-class push analytics payload from the top-level FCM `data` + * keys written by push-services. Returns `null` when the required identity + * fields are missing (e.g. a malformed or legacy payload), so callers can + * safely bail out. + * + * @param data - the top-level FCM `data` map (all values are strings). + * @returns the analytics payload, or `null` if required fields are absent. + */ +export function toPushAnalyticsPayload( + data: Record | undefined, +): PushAnalyticsPayload | null { + if (!data?.notification_id || !data?.notification_type) { + return null; + } + + return { + notification_id: data.notification_id, + notification_type: data.notification_type, + notification_subtype: data.notification_subtype ?? '', + profile_id: data.profile_id || undefined, + chain_id: data.chain_id ? Number(data.chain_id) : undefined, + deeplink: data.deeplink || undefined, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts index 6672c71653..8bd60b2fc6 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts @@ -15,6 +15,7 @@ import log from 'loglevel'; import type { NotificationServicesPushControllerMessenger } from '../NotificationServicesPushController'; import type { PushAnalyticsPayload } from '../types'; import type { PushNotificationEnv } from '../types/firebase'; +import { toPushAnalyticsPayload } from '../utils/to-push-analytics-payload'; declare const self: ServiceWorkerGlobalScope; @@ -150,32 +151,6 @@ async function listenToPushNotificationsReceived( return unsubscribe; } -/** - * Builds the first-class push analytics payload from the top-level FCM `data` - * keys written by push-services. Returns `null` when the required identity - * fields are missing (e.g. a malformed or legacy payload), so callers can - * safely bail out. - * - * @param data - the top-level FCM `data` map (all values are strings). - * @returns the analytics payload, or `null` if required fields are absent. - */ -export function toPushAnalyticsPayload( - data: Record | undefined, -): PushAnalyticsPayload | null { - if (!data?.notification_id || !data?.notification_type) { - return null; - } - - return { - notification_id: data.notification_id, - notification_type: data.notification_type, - notification_subtype: data.notification_subtype ?? '', - profile_id: data.profile_id || undefined, - chain_id: data.chain_id ? Number(data.chain_id) : undefined, - deeplink: data.deeplink || undefined, - }; -} - /** * Service Worker Listener for when a notification is clicked *