diff --git a/CHANGELOG.md b/CHANGELOG.md index a3689fb9ea..f8e8b219cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Parse Server Changelog +### master +[Full Changelog](https://github.com/ParsePlatform/parse-server/compare/2.6.0...master) + +#### New Features +* Adds ability to send localized pushes according to the _Installation localeIdentifier + ### 2.6.0 [Full Changelog](https://github.com/ParsePlatform/parse-server/compare/2.5.3...2.6.0) diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 57c9de60d2..e159dd8235 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -847,7 +847,7 @@ describe('PushController', () => { }); }); - it('should mark the _PushStatus as succeeded when audience has no deviceToken', (done) => { + it('should mark the _PushStatus as failed when audience has no deviceToken', (done) => { var auth = { isMaster: true } @@ -913,4 +913,70 @@ describe('PushController', () => { done(); }); }); + + it('should support localized payload data', (done) => { + var payload = {data: { + alert: 'Hello!', + 'alert-fr': 'Bonjour', + 'alert-es': 'Ola' + }} + + var pushAdapter = { + send: function(body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function() { + return ["ios"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + const where = { + 'deviceType': 'ios' + } + spyOn(pushAdapter, 'send').and.callThrough(); + var pushController = new PushController(); + reconfigureServer({ + push: { adapter: pushAdapter } + }).then(() => { + var installations = []; + while (installations.length != 5) { + const installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_" + installations.length); + installation.set("deviceToken", "device_token_" + installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + installations[0].set('localeIdentifier', 'fr-CA'); + installations[1].set('localeIdentifier', 'fr-FR'); + installations[2].set('localeIdentifier', 'en-US'); + return Parse.Object.saveAll(installations); + }).then(() => { + return pushController.sendPush(payload, where, config, auth) + }).then(() => { + // Wait so the push is completed. + return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); + }).then(() => { + expect(pushAdapter.send.calls.count()).toBe(2); + const firstCall = pushAdapter.send.calls.first(); + expect(firstCall.args[0].data).toEqual({ + alert: 'Hello!' + }); + expect(firstCall.args[1].length).toBe(3); // 3 installations + + const lastCall = pushAdapter.send.calls.mostRecent(); + expect(lastCall.args[0].data).toEqual({ + alert: 'Bonjour' + }); + expect(lastCall.args[1].length).toBe(2); // 2 installations + // No installation is in es so only 1 call for fr, and another for default + done(); + }).catch(done.fail); + }); }); diff --git a/spec/PushWorker.spec.js b/spec/PushWorker.spec.js index 8b392d12e2..b186c6e3f8 100644 --- a/spec/PushWorker.spec.js +++ b/spec/PushWorker.spec.js @@ -1,4 +1,5 @@ var PushWorker = require('../src').PushWorker; +var PushUtils = require('../src/Push/utils'); var Config = require('../src/Config'); describe('PushWorker', () => { @@ -54,4 +55,105 @@ describe('PushWorker', () => { jfail(err); }) }); + + describe('localized push', () => { + it('should return locales', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + 'alert-fr': 'french', + 'alert': 'Yo!', + 'alert-en-US': 'English', + } + }); + expect(locales).toEqual(['fr', 'en-US']); + }); + + it('should return and empty array if no locale is set', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + 'alert': 'Yo!', + } + }); + expect(locales).toEqual([]); + }); + + it('should deduplicate locales', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + 'alert': 'Yo!', + 'alert-fr': 'french', + 'title-fr': 'french' + } + }); + expect(locales).toEqual(['fr']); + }); + + it('transforms body appropriately', () => { + const cleanBody = PushUtils.transformPushBodyForLocale({ + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + } + }, 'fr'); + expect(cleanBody).toEqual({ + data: { + alert: 'frenchy!' + } + }); + }); + + it('transforms body appropriately', () => { + const cleanBody = PushUtils.transformPushBodyForLocale({ + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title' + } + }, 'fr'); + expect(cleanBody).toEqual({ + data: { + alert: 'frenchy!', + title: 'french title' + } + }); + }); + + it('maps body on all provided locales', () => { + const bodies = PushUtils.bodiesPerLocales({ + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title' + } + }, ['fr', 'en']); + expect(bodies).toEqual({ + fr: { + data: { + alert: 'frenchy!', + title: 'french title' + } + }, + en: { + data: { + alert: 'english', + } + }, + default: { + data: { + alert: 'Yo!' + } + } + }); + }); + + it('should properly handle default cases', () => { + expect(PushUtils.transformPushBodyForLocale({})).toEqual({}); + expect(PushUtils.stripLocalesFromBody({})).toEqual({}); + expect(PushUtils.bodiesPerLocales({where: {}})).toEqual({default: {where: {}}}); + expect(PushUtils.groupByLocaleIdentifier([])).toEqual({default: []}); + }); + }); }); diff --git a/src/Push/PushWorker.js b/src/Push/PushWorker.js index 1c50a1e0d5..b6d23955cb 100644 --- a/src/Push/PushWorker.js +++ b/src/Push/PushWorker.js @@ -65,6 +65,22 @@ export class PushWorker { sendToAdapter(body: any, installations: any[], pushStatus: any, config: Config): Promise<*> { pushStatus = pushStatusHandler(config, pushStatus.objectId); + // Check if we have locales in the push body + const locales = utils.getLocalesFromPush(body); + if (locales.length > 0) { + // Get all tranformed bodies for each locale + const bodiesPerLocales = utils.bodiesPerLocales(body, locales); + + // Group installations on the specified locales (en, fr, default etc...) + const grouppedInstallations = utils.groupByLocaleIdentifier(installations, locales); + const promises = Object.keys(grouppedInstallations).map((locale) => { + const installations = grouppedInstallations[locale]; + const body = bodiesPerLocales[locale]; + return this.sendToAdapter(body, installations, pushStatus, config); + }); + return Promise.all(promises); + } + if (!utils.isPushIncrementing(body)) { return this.adapter.send(body, installations, pushStatus.objectId).then((results) => { return pushStatus.trackSent(results); diff --git a/src/Push/utils.js b/src/Push/utils.js index 364003a5a4..ed33a66a14 100644 --- a/src/Push/utils.js +++ b/src/Push/utils.js @@ -8,6 +8,81 @@ export function isPushIncrementing(body) { body.data.badge.toLowerCase() == "increment" } +const localizableKeys = ['alert', 'title']; + +export function getLocalesFromPush(body) { + const data = body.data; + if (!data) { + return []; + } + return [...new Set(Object.keys(data).reduce((memo, key) => { + localizableKeys.forEach((localizableKey) => { + if (key.indexOf(`${localizableKey}-`) == 0) { + memo.push(key.slice(localizableKey.length + 1)); + } + }); + return memo; + }, []))]; +} + +export function transformPushBodyForLocale(body, locale) { + const data = body.data; + if (!data) { + return body; + } + body = deepcopy(body); + localizableKeys.forEach((key) => { + const localeValue = body.data[`${key}-${locale}`]; + if (localeValue) { + body.data[key] = localeValue; + } + }); + return stripLocalesFromBody(body); +} + +export function stripLocalesFromBody(body) { + if (!body.data) { return body; } + Object.keys(body.data).forEach((key) => { + localizableKeys.forEach((localizableKey) => { + if (key.indexOf(`${localizableKey}-`) == 0) { + delete body.data[key]; + } + }); + }); + return body; +} + +export function bodiesPerLocales(body, locales = []) { + // Get all tranformed bodies for each locale + const result = locales.reduce((memo, locale) => { + memo[locale] = transformPushBodyForLocale(body, locale); + return memo; + }, {}); + // Set the default locale, with the stripped body + result.default = stripLocalesFromBody(body); + return result; +} + +export function groupByLocaleIdentifier(installations, locales = []) { + return installations.reduce((map, installation) => { + let added = false; + locales.forEach((locale) => { + if (added) { + return; + } + if (installation.localeIdentifier && installation.localeIdentifier.indexOf(locale) === 0) { + added = true; + map[locale] = map[locale] || []; + map[locale].push(installation); + } + }); + if (!added) { + map.default.push(installation); + } + return map; + }, {default: []}); +} + /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition diff --git a/src/Routers/PushRouter.js b/src/Routers/PushRouter.js index ace3cf80b0..03889c1c6f 100644 --- a/src/Routers/PushRouter.js +++ b/src/Routers/PushRouter.js @@ -28,7 +28,7 @@ export class PushRouter extends PromiseRouter { result: true } }); - }); + }).catch(req.config.loggerController.error); return promise; }