-
-
Notifications
You must be signed in to change notification settings - Fork 280
feat: update notification-services-controller to support new Segment schema #8944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
602241e
b765c4a
16656ed
e3aabce
5ec0e5b
d94df65
3400f69
e68d492
81b5f29
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
| @@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Invalid chain_id becomes NaNLow Severity
Reviewed by Cursor Bugbot for commit 81b5f29. Configure here. |
||
| deeplink: data.deeplink || undefined, | ||
| }; | ||
| } | ||


There was a problem hiding this comment.
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
fetchAndUpdateMetamaskNotificationsreuses existing snap rows from state as-is, so persisted snap notifications never gain the new requirednotification_subtypefield unless they are replaced by a freshprocessSnapNotificationpath.Reviewed by Cursor Bugbot for commit b1c77d8. Configure here.