Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push
- [Migration to FCM HTTP v1 API (June 2024)](#migration-to-fcm-http-v1-api-june-2024)
- [HTTP/1.1 Legacy Option](#http11-legacy-option)
- [Firebase Client Error](#firebase-client-error)
- [FCM Analytics Label](#fcm-analytics-label)
- [Expo Push Options](#expo-push-options)
- [Bundled with Parse Server](#bundled-with-parse-server)
- [Logging](#logging)
Expand Down Expand Up @@ -189,6 +190,22 @@ Occasionally, errors within the Firebase Cloud Messaging (FCM) client may not be

In both cases, detailed error logs are recorded in the Parse Server logs for debugging purposes.

#### FCM Analytics Label

To tag Firebase delivery metrics for a push notification, include `analytics_label` in the push data. The adapter validates the label and maps it to `fcmOptions.analyticsLabel` in the FCM payload.

```js
await Parse.Push.send({
channels: ['global'],
data: {
alert: 'Feature update',
analytics_label: 'feature_update_v1'
}
}, { useMasterKey: true });
```

The analytics label can contain 1 to 50 letters, numbers, or `-_.~%` characters.

### Expo Push Options

Example options:
Expand Down
94 changes: 94 additions & 0 deletions spec/FCM.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,79 @@ describe('FCM', () => {
expect(payload.data.tokens).toEqual(['testToken']);
});

it('can add an FCM analytics label from nested request data', () => {
const requestData = {
data: {
alert: 'alert',
analytics_label: 'feature_update_v1',
},
};

const payload = FCM.generateFCMPayload(
requestData,
'pushId',
1454538822113,
['testToken'],
'android',
);

expect(payload.data.fcmOptions).toEqual({
analyticsLabel: 'feature_update_v1',
});
expect(payload.data.tokens).toEqual(['testToken']);

const dataFromUser = JSON.parse(payload.data.android.data.data);
expect(dataFromUser).toEqual({ alert: 'alert' });
});

it('can add an FCM analytics label from top-level request data', () => {
const requestData = {
analytics_label: 'campaign-1',
data: {
alert: 'alert',
},
};

const payload = FCM.generateFCMPayload(
requestData,
'pushId',
1454538822113,
['testToken'],
'android',
);

expect(payload.data.fcmOptions).toEqual({
analyticsLabel: 'campaign-1',
});
});

it('rejects invalid FCM analytics labels', () => {
const invalidLabels = [
'',
'has spaces',
'has/slash',
'a'.repeat(51),
123,
];

invalidLabels.forEach((analyticsLabel) => {
const requestData = {
data: {
alert: 'alert',
analytics_label: analyticsLabel,
},
};

expect(() => FCM.generateFCMPayload(
requestData,
'pushId',
1454538822113,
['testToken'],
'android',
)).toThrowError('FCM analytics_label must contain 1 to 50 letters, numbers, or -_.~% characters.');
});
});

it('can slice devices', () => {
// Mock devices
const devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)];
Expand Down Expand Up @@ -586,6 +659,27 @@ describe('FCM', () => {
expect(fcmPayload.apns.headers['apns-priority']).toEqual(priority);
});

it('can add an FCM analytics label to APNS payloads', () => {
const data = {
analytics_label: 'ios_campaign',
alert: 'alert',
};

const payload = FCM.generateFCMPayload(
data,
'pushId',
1454538822113,
['tokenTest'],
'apple',
);
const fcmPayload = payload.data;

expect(fcmPayload.fcmOptions).toEqual({
analyticsLabel: 'ios_campaign',
});
expect(fcmPayload.apns.payload.analytics_label).toBeUndefined();
});

it('sets push type to alert if not defined explicitly', () => {
const data = {
alert: 'alert',
Expand Down
69 changes: 64 additions & 5 deletions src/FCM.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import { randomString } from './PushAdapterUtils.js';
const LOG_PREFIX = 'parse-server-push-adapter FCM';
const FCMRegistrationTokensMax = 500;
const FCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // FCM allows a max of 4 weeks
const fcmAnalyticsLabelRegex = /^[a-zA-Z0-9-_.~%]{1,50}$/;
const apnsIntegerDataKeys = [
'badge',
'content-available',
'mutable-content',
'priority',
'expiration_time',
];
const fcmMetadataDataKeys = [
'analytics_label',
];

export default function FCM(args, pushType) {
if (typeof args !== 'object' || !args.firebaseServiceAccount) {
Expand Down Expand Up @@ -261,6 +265,8 @@ function _APNSToFCMPayload(requestData) {
break;
case 'priority':
break;
case 'analytics_label':
break;
default:
apnsPayload['apns']['payload'][key] = coreData[key]; // Custom keys should be outside aps
break;
Expand All @@ -282,16 +288,22 @@ function _GCMToFCMPayload(requestData, pushId, timeStamp) {
}

if (requestData.hasOwnProperty('data')) {
const data = { ...requestData.data };
// FCM gives an error on send if we have apns keys that should have integer values
for (const key of apnsIntegerDataKeys) {
if (requestData.data.hasOwnProperty(key)) {
delete requestData.data[key]
if (data.hasOwnProperty(key)) {
delete data[key];
}
}
for (const key of fcmMetadataDataKeys) {
if (data.hasOwnProperty(key)) {
delete data[key];
}
}
androidPayload.android.data = {
push_id: pushId,
time: new Date(timeStamp).toISOString(),
data: JSON.stringify(requestData.data),
data: JSON.stringify(data),
}
}

Expand All @@ -312,6 +324,49 @@ function _GCMToFCMPayload(requestData, pushId, timeStamp) {
return androidPayload;
}

function getAnalyticsLabel(requestData) {
let analyticsLabel;
const hasTopLevelAnalyticsLabel = requestData.hasOwnProperty('analytics_label');
const hasDataAnalyticsLabel = requestData.data &&
typeof requestData.data === 'object' &&
requestData.data.hasOwnProperty('analytics_label');

if (hasTopLevelAnalyticsLabel) {
analyticsLabel = requestData.analytics_label;
} else if (hasDataAnalyticsLabel) {
analyticsLabel = requestData.data.analytics_label;
}

if (analyticsLabel === undefined) {
return undefined;
}

if (typeof analyticsLabel !== 'string' || !fcmAnalyticsLabelRegex.test(analyticsLabel)) {
throw new Parse.Error(
Parse.Error.PUSH_MISCONFIGURED,
'FCM analytics_label must contain 1 to 50 letters, numbers, or -_.~% characters.',
);
}

return analyticsLabel;
}

function applyAnalyticsLabel(fcmPayload, requestData) {
const analyticsLabel = getAnalyticsLabel(requestData);

if (!analyticsLabel) {
return fcmPayload;
}

return {
...fcmPayload,
fcmOptions: {
...fcmPayload.fcmOptions,
analyticsLabel,
},
};
}

/**
* Converts payloads used by APNS or GCM into a FCMv1-compatible payload.
* Purpose is to remain backwards-compatible will payloads used in the APNS.js and GCM.js modules.
Expand All @@ -327,16 +382,20 @@ function payloadConverter(requestData, pushType, pushId, timeStamp) {
return requestData.rawPayload;
}

let fcmPayload;

if (pushType === 'apple') {
return _APNSToFCMPayload(requestData);
fcmPayload = _APNSToFCMPayload(requestData);
} else if (pushType === 'android') {
return _GCMToFCMPayload(requestData, pushId, timeStamp);
fcmPayload = _GCMToFCMPayload(requestData, pushId, timeStamp);
} else {
throw new Parse.Error(
Parse.Error.PUSH_MISCONFIGURED,
'Unsupported push type, apple or android only.',
);
}

return applyAnalyticsLabel(fcmPayload, requestData);
}

/**
Expand Down