Skip to content
17 changes: 17 additions & 0 deletions packages/notification-services-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ 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`). ([#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

- **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. ([#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. ([#8944](https://github.com/MetaMask/core/pull/8944))

## [24.1.2]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,7 @@ describe('NotificationServicesController', () => {
expect(filteredNotifications).toStrictEqual([
{
type: TRIGGER_TYPES.SNAP,
notification_subtype: TRIGGER_TYPES.SNAP,
id: expect.any(String),
createdAt: expect.any(String),
isRead: false,
Expand Down Expand Up @@ -1605,6 +1606,7 @@ describe('NotificationServicesController', () => {
expect(controller.state.metamaskNotificationsList).toStrictEqual([
{
type: TRIGGER_TYPES.SNAP,
notification_subtype: TRIGGER_TYPES.SNAP,
id: expect.any(String),
createdAt: expect.any(String),
readDate: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snap subtype not backfilled

Medium Severity

fetchAndUpdateMetamaskNotifications reuses existing snap rows from state as-is, so persisted snap notifications never gain the new required notification_subtype field unless they are replaced by a fresh processSnapNotification path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1c77d8. Configure here.

},
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -17,6 +18,7 @@ export function processAPINotifications(
return {
...notification,
id: notification.id,
notification_subtype: getNotificationSubtype(notification),
createdAt: createdAtDate.toISOString(),
isRead: expired || !notification.unread,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -32,6 +33,7 @@ export function processFeatureAnnouncement(
return {
type: notification.type,
id: notification.data.id,
notification_subtype: getNotificationSubtype(notification),
createdAt: new Date(notification.createdAt).toISOString(),
data: notification.data,
isRead: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -15,6 +16,7 @@ export const processSnapNotification = (
const { data, type, readDate } = snapNotification;
return {
id: uuid(),
notification_subtype: getNotificationSubtype(snapNotification),
readDate,
createdAt: new Date().toISOString(),
isRead: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { TRIGGER_TYPES } from '../constants/notification-schema';
import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements';
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';

describe('getNotificationSubtype', () => {
it('returns the trigger kind for on-chain notifications', () => {
const notification = processNotification(
createMockNotificationEthReceived(),
);
expect(getNotificationSubtype(notification)).toBe(
TRIGGER_TYPES.ETH_RECEIVED,
);
});

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);
});

it('returns a stable label for feature-announcement notifications', () => {
const notification = processNotification(
createMockFeatureAnnouncementRaw(),
);
expect(getNotificationSubtype(notification)).toBe(
TRIGGER_TYPES.FEATURES_ANNOUNCEMENT,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { TRIGGER_TYPES } from '../constants/notification-schema';
import type { RawNotificationUnion } 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 (`payload.data.kind`, e.g. `eth_received`).
* - snap: the snap notification type (`snap`, already snake_case).
* - 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`. 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 raw or processed notification.
* @returns the normalised subtype 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
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs validation

// 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: 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`. It is
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs validation

// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type * from './firebase';
export type * from './push-analytics';
export type * from './push-service-interface';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// snake_case mirrors the FCM payload and Segment schema keys
/* eslint-disable @typescript-eslint/naming-convention */

/**
* 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;
/** 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 cross-check; 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;
};
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './get-notification-data';
export * from './get-notification-message';
export * from './to-push-analytics-payload';
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined,
),
).toBeNull();
},
);
});
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid chain_id becomes NaN

Low Severity

toPushAnalyticsPayload sets chain_id with Number(data.chain_id) whenever the FCM string is truthy, without checking the result. A non-numeric chain_id value becomes NaN, which still satisfies the optional number type but breaks downstream analytics or routing that expect a valid chain id.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 81b5f29. Configure here.

deeplink: data.deeplink || undefined,
};
}
Loading
Loading