From f5bcc3a082e5609b094c1508c434973165245472 Mon Sep 17 00:00:00 2001 From: ManojNB Date: Tue, 10 Oct 2023 10:54:10 -0700 Subject: [PATCH] feat(inApp): functional dispatchEvent & setConflictHandler APIs (#12231) * chore: truncate comments and typo --------- Co-authored-by: Aaron S <94858815+stocaaro@users.noreply.github.com> Co-authored-by: Jim Blanchard --- .../aws-amplify/__tests__/exports.test.ts | 4 + packages/core/src/Platform/types.ts | 1 + packages/notifications/__mocks__/data.ts | 10 +- .../pinpoint/apis/dispatchEvent.test.ts | 76 ++++ .../pinpoint/apis/setConflictHandler.test.ts | 54 +++ .../utils/processInAppMessages.test.ts | 59 +++ .../notifications/src/inAppMessaging/index.ts | 7 +- .../src/inAppMessaging/providers/index.ts | 7 +- .../providers/pinpoint/apis/dispatchEvent.ts | 59 +++ .../providers/pinpoint/apis/index.ts | 2 + .../pinpoint/apis/setConflictHandler.ts | 66 ++++ .../providers/pinpoint/index.ts | 7 +- .../providers/pinpoint/types/index.ts | 14 +- .../providers/pinpoint/types/inputs.ts | 17 +- .../providers/pinpoint/types/types.ts | 8 +- .../providers/pinpoint/utils/helpers.ts | 340 ++++++++++++++++++ .../providers/pinpoint/utils/index.ts | 2 + .../pinpoint/utils/processInAppMessages.ts | 144 ++++++++ .../src/inAppMessaging/types/index.ts | 8 +- 19 files changed, 874 insertions(+), 11 deletions(-) create mode 100644 packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/dispatchEvent.test.ts create mode 100644 packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/setConflictHandler.test.ts create mode 100644 packages/notifications/__tests__/inAppMessaging/utils/processInAppMessages.test.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/apis/dispatchEvent.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/apis/setConflictHandler.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/utils/helpers.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/utils/processInAppMessages.ts diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 982ac31b131..28740921bc9 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -102,6 +102,8 @@ describe('aws-amplify Exports', () => { Array [ "identifyUser", "syncMessages", + "dispatchEvent", + "setConflictHandler", ] `); }); @@ -112,6 +114,8 @@ describe('aws-amplify Exports', () => { Array [ "identifyUser", "syncMessages", + "dispatchEvent", + "setConflictHandler", ] `); }); diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 003c9018838..8536db6c01e 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -88,6 +88,7 @@ export enum GeoAction { export enum InAppMessagingAction { SyncMessages = '1', IdentifyUser = '2', + DispatchEvent = '3', } export enum InteractionsAction { None = '0', diff --git a/packages/notifications/__mocks__/data.ts b/packages/notifications/__mocks__/data.ts index a4afbef9c62..51077ce0a53 100644 --- a/packages/notifications/__mocks__/data.ts +++ b/packages/notifications/__mocks__/data.ts @@ -172,16 +172,20 @@ export const pinpointInAppMessage: PinpointInAppMessage = { }, Priority: 3, Schedule: { - EndDate: '2021-01-01T00:00:00Z', + EndDate: '2024-01-01T00:00:00Z', EventFilter: { FilterType: 'SYSTEM', Dimensions: { - Attributes: {}, + Attributes: { + interests: { Values: ['test-interest'] }, + }, EventType: { DimensionType: 'INCLUSIVE', Values: ['clicked', 'swiped'], }, - Metrics: {}, + Metrics: { + clicks: { ComparisonOperator: 'EQUAL', Value: 5 }, + }, }, }, QuietTime: { diff --git a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/dispatchEvent.test.ts b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/dispatchEvent.test.ts new file mode 100644 index 00000000000..d7f7c3a5e4f --- /dev/null +++ b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/dispatchEvent.test.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defaultStorage } from '@aws-amplify/core'; +import { dispatchEvent } from '../../../../../src/inAppMessaging/providers/pinpoint/apis'; +import { processInAppMessages } from '../../../../../src/inAppMessaging/providers/pinpoint/utils'; +import { + inAppMessages, + simpleInAppMessages, + simpleInAppMessagingEvent, +} from '../../../../../__mocks__/data'; +import { InAppMessagingError } from '../../../../../src/inAppMessaging/errors'; +import { notifyEventListeners } from '../../../../../src/common/eventListeners'; + +jest.mock('@aws-amplify/core'); +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('../../../../../src/inAppMessaging/providers/pinpoint/utils'); +jest.mock('../../../../../src/common/eventListeners'); + +const mockDefaultStorage = defaultStorage as jest.Mocked; +const mockNotifyEventListeners = notifyEventListeners as jest.Mock; +const mockProcessInAppMessages = processInAppMessages as jest.Mock; + +describe('dispatchEvent', () => { + beforeEach(() => { + mockDefaultStorage.setItem.mockClear(); + mockNotifyEventListeners.mockClear(); + }); + test('gets in-app messages from store and notifies listeners', async () => { + const [message] = inAppMessages; + mockDefaultStorage.getItem.mockResolvedValueOnce( + JSON.stringify(simpleInAppMessages) + ); + mockProcessInAppMessages.mockReturnValueOnce([message]); + await dispatchEvent(simpleInAppMessagingEvent); + expect(mockProcessInAppMessages).toBeCalledWith( + simpleInAppMessages, + simpleInAppMessagingEvent + ); + expect(mockNotifyEventListeners).toBeCalledWith('messageReceived', message); + }); + + test('handles conflicts through default conflict handler', async () => { + mockDefaultStorage.getItem.mockResolvedValueOnce( + JSON.stringify(simpleInAppMessages) + ); + mockProcessInAppMessages.mockReturnValueOnce(inAppMessages); + await dispatchEvent(simpleInAppMessagingEvent); + expect(mockProcessInAppMessages).toBeCalledWith( + simpleInAppMessages, + simpleInAppMessagingEvent + ); + expect(mockNotifyEventListeners).toBeCalledWith( + 'messageReceived', + inAppMessages[4] + ); + }); + + test('does not notify listeners if no messages are returned', async () => { + mockProcessInAppMessages.mockReturnValueOnce([]); + mockDefaultStorage.getItem.mockResolvedValueOnce( + JSON.stringify(simpleInAppMessages) + ); + + await dispatchEvent(simpleInAppMessagingEvent); + + expect(mockNotifyEventListeners).not.toBeCalled(); + }); + + test('logs error if storage retrieval fails', async () => { + mockDefaultStorage.getItem.mockRejectedValueOnce(Error); + await expect( + dispatchEvent(simpleInAppMessagingEvent) + ).rejects.toStrictEqual(expect.any(InAppMessagingError)); + }); +}); diff --git a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/setConflictHandler.test.ts b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/setConflictHandler.test.ts new file mode 100644 index 00000000000..b8bae764502 --- /dev/null +++ b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/setConflictHandler.test.ts @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defaultStorage } from '@aws-amplify/core'; +import { + dispatchEvent, + setConflictHandler, +} from '../../../../../src/inAppMessaging/providers/pinpoint/apis'; +import { processInAppMessages } from '../../../../../src/inAppMessaging/providers/pinpoint/utils'; +import { + closestExpiryMessage, + customHandledMessage, + inAppMessages, + simpleInAppMessagingEvent, +} from '../../../../../__mocks__/data'; +import { notifyEventListeners } from '../../../../../src/common/eventListeners'; + +jest.mock('@aws-amplify/core'); +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('../../../../../src/inAppMessaging/providers/pinpoint/utils'); +jest.mock('../../../../../src/common/eventListeners'); + +const mockDefaultStorage = defaultStorage as jest.Mocked; +const mockNotifyEventListeners = notifyEventListeners as jest.Mock; +const mockProcessInAppMessages = processInAppMessages as jest.Mock; + +describe('Conflict handling', () => { + beforeEach(() => { + mockDefaultStorage.setItem.mockClear(); + mockNotifyEventListeners.mockClear(); + }); + test('has a default implementation', async () => { + mockProcessInAppMessages.mockReturnValueOnce(inAppMessages); + await dispatchEvent(simpleInAppMessagingEvent); + expect(mockNotifyEventListeners).toBeCalledWith( + 'messageReceived', + closestExpiryMessage + ); + }); + + test('can be customized through setConflictHandler', async () => { + const customConflictHandler = messages => + messages.find(message => message.id === 'custom-handled'); + mockProcessInAppMessages.mockReturnValueOnce(inAppMessages); + + setConflictHandler(customConflictHandler); + await dispatchEvent(simpleInAppMessagingEvent); + + expect(mockNotifyEventListeners).toBeCalledWith( + 'messageReceived', + customHandledMessage + ); + }); +}); diff --git a/packages/notifications/__tests__/inAppMessaging/utils/processInAppMessages.test.ts b/packages/notifications/__tests__/inAppMessaging/utils/processInAppMessages.test.ts new file mode 100644 index 00000000000..7d43a650050 --- /dev/null +++ b/packages/notifications/__tests__/inAppMessaging/utils/processInAppMessages.test.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + pinpointInAppMessage, + simpleInAppMessagingEvent, +} from '../../../__mocks__/data'; +import { processInAppMessages } from '../../../src/inAppMessaging/providers/pinpoint/utils/processInAppMessages'; +import { cloneDeep } from 'lodash'; +import { + isBeforeEndDate, + matchesAttributes, + matchesEventType, + matchesMetrics, +} from '../../../src/inAppMessaging/providers/pinpoint/utils/helpers'; + +jest.mock('@aws-amplify/core'); +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('../../../src/inAppMessaging/providers/pinpoint/utils/helpers'); + +const mockIsBeforeEndDate = isBeforeEndDate as jest.Mock; +const mockMatchesAttributes = matchesAttributes as jest.Mock; +const mockMatchesEventType = matchesEventType as jest.Mock; +const mockMatchesMetrics = matchesMetrics as jest.Mock; + +// TODO(V6): Add tests for session cap etc +describe('processInAppMessages', () => { + const messages = [ + cloneDeep(pinpointInAppMessage), + { ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-2', Priority: 3 }, + { ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-3', Priority: 1 }, + { ...cloneDeep(pinpointInAppMessage), CampaignId: 'uuid-4', Priority: 2 }, + ]; + beforeEach(() => { + mockMatchesEventType.mockReturnValue(true); + mockMatchesAttributes.mockReturnValue(true); + mockMatchesMetrics.mockReturnValue(true); + mockIsBeforeEndDate.mockReturnValue(true); + }); + + test('filters in-app messages from Pinpoint by criteria', async () => { + mockMatchesEventType.mockReturnValueOnce(false); + mockMatchesAttributes.mockReturnValueOnce(false); + mockMatchesMetrics.mockReturnValueOnce(false); + const [result] = await processInAppMessages( + messages, + simpleInAppMessagingEvent + ); + expect(result.id).toBe('uuid-4'); + }); + + test('filters in-app messages from Pinpoint by criteria', async () => { + const [result] = await processInAppMessages( + messages, + simpleInAppMessagingEvent + ); + expect(result.id).toBe('uuid-3'); + }); +}); diff --git a/packages/notifications/src/inAppMessaging/index.ts b/packages/notifications/src/inAppMessaging/index.ts index 9152b0d2973..5bfbae5da51 100644 --- a/packages/notifications/src/inAppMessaging/index.ts +++ b/packages/notifications/src/inAppMessaging/index.ts @@ -1,4 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { identifyUser, syncMessages } from './providers/pinpoint'; +export { + identifyUser, + syncMessages, + dispatchEvent, + setConflictHandler, +} from './providers/pinpoint'; diff --git a/packages/notifications/src/inAppMessaging/providers/index.ts b/packages/notifications/src/inAppMessaging/providers/index.ts index 54b4514593e..51aec634a0c 100644 --- a/packages/notifications/src/inAppMessaging/providers/index.ts +++ b/packages/notifications/src/inAppMessaging/providers/index.ts @@ -1,4 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { identifyUser, syncMessages } from './pinpoint/apis'; +export { + identifyUser, + syncMessages, + dispatchEvent, + setConflictHandler, +} from './pinpoint/apis'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/dispatchEvent.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/dispatchEvent.ts new file mode 100644 index 00000000000..212e8548139 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/dispatchEvent.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PINPOINT_KEY_PREFIX, + STORAGE_KEY_SUFFIX, + processInAppMessages, +} from '../utils'; +import { InAppMessage } from '../../../types'; +import flatten from 'lodash/flatten'; +import { defaultStorage } from '@aws-amplify/core'; +import { notifyEventListeners } from '../../../../common'; +import { assertServiceError } from '../../../errors'; +import { DispatchEventInput } from '../types'; +import { syncMessages } from './syncMessages'; +import { conflictHandler, setConflictHandler } from './setConflictHandler'; + +/** + * Triggers an In-App message to be displayed. Use this after your campaigns have been synced to the device using + * {@link syncMessages}. Based on the messages synced and the event passed to this API, it triggers the display + * of the In-App message that meets the criteria. + * To change the conflict handler, use the {@link setConflictHandler} API. + * + * @param DispatchEventInput The input object that holds the event to be dispatched. + * + * @throws service exceptions - Thrown when the underlying Pinpoint service returns an error. + * + * @returns A promise that will resolve when the operation is complete. + * + * @example + * ```ts + * // Sync message before disptaching an event + * await syncMessages(); + * + * // Dispatch an event + * await dispatchEvent({ name: "test_event" }); + * ``` + */ +export async function dispatchEvent(input: DispatchEventInput): Promise { + try { + const key = `${PINPOINT_KEY_PREFIX}${STORAGE_KEY_SUFFIX}`; + const cachedMessages = await defaultStorage.getItem(key); + const messages: InAppMessage[] = await processInAppMessages( + cachedMessages ? JSON.parse(cachedMessages) : [], + input + ); + const flattenedMessages = flatten(messages); + + if (flattenedMessages.length > 0) { + notifyEventListeners( + 'messageReceived', + conflictHandler(flattenedMessages) + ); + } + } catch (error) { + assertServiceError(error); + throw error; + } +} diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/index.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/index.ts index b2ca836fa33..d25f562d165 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/index.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/index.ts @@ -3,3 +3,5 @@ export { identifyUser } from './identifyUser'; export { syncMessages } from './syncMessages'; +export { dispatchEvent } from './dispatchEvent'; +export { setConflictHandler } from './setConflictHandler'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/setConflictHandler.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/setConflictHandler.ts new file mode 100644 index 00000000000..052450551ff --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/setConflictHandler.ts @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { InAppMessage } from '../../../types'; +import { InAppMessageConflictHandler, SetConflictHandlerInput } from '../types'; + +export let conflictHandler: InAppMessageConflictHandler = + defaultConflictHandler; + +/** + * Set a conflict handler that will be used to resolve conflicts that may emerge + * when matching events with synced messages. + * @remark + * The conflict handler is not persisted between sessions + * and needs to be called before dispatching an event to have any effect. + * + * @param SetConflictHandlerInput: The input object that holds the conflict handler to be used. + * + * + * @example + * ```ts + * // Sync messages before dispatching an event + * await syncMessages(); + * + * // Example custom conflict handler + * const myConflictHandler = (messages) => { + * // Return a random message + * const randomIndex = Math.floor(Math.random() * messages.length); + * return messages[randomIndex]; + * }; + * + * // Set the conflict handler + * setConflictHandler(myConflictHandler); + * + * // Dispatch an event + * await dispatchEvent({ name: "test_event" }); + * ``` + */ +export function setConflictHandler(input: SetConflictHandlerInput): void { + conflictHandler = input; +} + +function defaultConflictHandler(messages: InAppMessage[]): InAppMessage { + // default behavior is to return the message closest to expiry + // this function assumes that messages processed by providers already filters out expired messages + const sorted = messages.sort((a, b) => { + const endDateA = a.metadata?.endDate; + const endDateB = b.metadata?.endDate; + // if both message end dates are falsy or have the same date string, treat them as equal + if (endDateA === endDateB) { + return 0; + } + // if only message A has an end date, treat it as closer to expiry + if (endDateA && !endDateB) { + return -1; + } + // if only message B has an end date, treat it as closer to expiry + if (!endDateA && endDateB) { + return 1; + } + // otherwise, compare them + return new Date(endDateA) < new Date(endDateB) ? -1 : 1; + }); + // always return the top sorted + return sorted[0]; +} diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/index.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/index.ts index 01e1384253c..970ae16eb7b 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/index.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/index.ts @@ -1,4 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { identifyUser, syncMessages } from './apis'; +export { + identifyUser, + syncMessages, + dispatchEvent, + setConflictHandler, +} from './apis'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts index fc8965c66b8..80d6de78c80 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts @@ -2,5 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 export { UpdateEndpointException } from './errors'; -export { IdentifyUserInput } from './inputs'; +export { + IdentifyUserInput, + DispatchEventInput, + SetConflictHandlerInput, +} from './inputs'; export { IdentifyUserOptions } from './options'; +export { + PinpointMessageEvent, + MetricsComparator, + InAppMessageCounts, + InAppMessageCountMap, + DailyInAppMessageCounter, + InAppMessageConflictHandler, +} from './types'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts index f103f97b69e..f2edad9b6dc 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts @@ -1,11 +1,24 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { IdentifyUserOptions } from '.'; -import { InAppMessagingIdentifyUserInput } from '../../../types'; +import { IdentifyUserOptions, InAppMessageConflictHandler } from '.'; +import { + InAppMessagingEvent, + InAppMessagingIdentifyUserInput, +} from '../../../types'; /** * Input type for Pinpoint identifyUser API. */ export type IdentifyUserInput = InAppMessagingIdentifyUserInput; + +/** + * Input type for Pinpoint dispatchEvent API. + */ +export type DispatchEventInput = InAppMessagingEvent; + +/** + * Input type for Pinpoint SetConflictHandler API. + */ +export type SetConflictHandlerInput = InAppMessageConflictHandler; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/types.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/types.ts index 68022a84e83..6c964507666 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/types.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/types.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { InAppMessage } from '../../../types'; + export type InAppMessageCountMap = Record; export type DailyInAppMessageCounter = { @@ -19,8 +21,12 @@ export type MetricsComparator = ( eventVal: number ) => boolean; -export enum AWSPinpointMessageEvent { +export enum PinpointMessageEvent { MESSAGE_DISPLAYED = '_inapp.message_displayed', MESSAGE_DISMISSED = '_inapp.message_dismissed', MESSAGE_ACTION_TAKEN = '_inapp.message_clicked', } + +export type InAppMessageConflictHandler = ( + messages: InAppMessage[] +) => InAppMessage; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/helpers.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/helpers.ts new file mode 100644 index 00000000000..58a625d41bd --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/helpers.ts @@ -0,0 +1,340 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Hub } from '@aws-amplify/core'; +import { + ConsoleLogger, + InAppMessagingAction, + AMPLIFY_SYMBOL, +} from '@aws-amplify/core/internals/utils'; +import type { InAppMessageCampaign as PinpointInAppMessage } from '@aws-amplify/core/internals/aws-clients/pinpoint'; +import isEmpty from 'lodash/isEmpty'; +import { + InAppMessage, + InAppMessageAction, + InAppMessageContent, + InAppMessageLayout, + InAppMessageTextAlign, + InAppMessagingEvent, +} from '../../../types'; +import { MetricsComparator, PinpointMessageEvent } from '../types'; +import { record as recordCore } from '@aws-amplify/core/internals/providers/pinpoint'; +import { resolveConfig } from './resolveConfig'; +import { resolveCredentials } from './resolveCredentials'; +import { CATEGORY } from './constants'; +import { getInAppMessagingUserAgentString } from './userAgent'; + +const DELIVERY_TYPE = 'IN_APP_MESSAGE'; + +let eventNameMemo = {}; +let eventAttributesMemo = {}; +let eventMetricsMemo = {}; + +export const logger = new ConsoleLogger('InAppMessaging.Pinpoint'); + +export const dispatchInAppMessagingEvent = ( + event: string, + data: any, + message?: string +) => { + Hub.dispatch( + 'inAppMessaging', + { event, data, message }, + 'InAppMessaging', + AMPLIFY_SYMBOL + ); +}; + +export const recordAnalyticsEvent = ( + event: PinpointMessageEvent, + message: InAppMessage +) => { + const { appId, region } = resolveConfig(); + + const { id, metadata } = message; + resolveCredentials() + .then(({ credentials, identityId }) => { + recordCore({ + appId, + category: CATEGORY, + credentials, + event: { + name: event, + attributes: { + campaign_id: id, + delivery_type: DELIVERY_TYPE, + treatment_id: metadata?.treatmentId, + }, + }, + identityId, + region, + userAgentValue: getInAppMessagingUserAgentString( + InAppMessagingAction.DispatchEvent + ), + }); + }) + .catch(e => { + // An error occured while fetching credentials or persisting the event to the buffer + logger.warn('Failed to record event.', e); + }); +}; + +export const getStartOfDay = (): string => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.toISOString(); +}; + +export const matchesEventType = ( + { CampaignId, Schedule }: PinpointInAppMessage, + { name: eventType }: InAppMessagingEvent +) => { + const { EventType } = Schedule?.EventFilter?.Dimensions; + const memoKey = `${CampaignId}:${eventType}`; + if (!eventNameMemo.hasOwnProperty(memoKey)) { + eventNameMemo[memoKey] = !!EventType?.Values.includes(eventType); + } + return eventNameMemo[memoKey]; +}; + +export const matchesAttributes = ( + { CampaignId, Schedule }: PinpointInAppMessage, + { attributes }: InAppMessagingEvent +): boolean => { + const { Attributes } = Schedule?.EventFilter?.Dimensions; + if (isEmpty(Attributes)) { + // if message does not have attributes defined it does not matter what attributes are on the event + return true; + } + if (isEmpty(attributes)) { + // if message does have attributes but the event does not then it always fails the check + return false; + } + const memoKey = `${CampaignId}:${JSON.stringify(attributes)}`; + if (!eventAttributesMemo.hasOwnProperty(memoKey)) { + eventAttributesMemo[memoKey] = Object.entries(Attributes).every( + ([key, { Values }]) => Values.includes(attributes[key]) + ); + } + return eventAttributesMemo[memoKey]; +}; + +export const matchesMetrics = ( + { CampaignId, Schedule }: PinpointInAppMessage, + { metrics }: InAppMessagingEvent +): boolean => { + const { Metrics } = Schedule?.EventFilter?.Dimensions; + if (isEmpty(Metrics)) { + // if message does not have metrics defined it does not matter what metrics are on the event + return true; + } + if (isEmpty(metrics)) { + // if message does have metrics but the event does not then it always fails the check + return false; + } + const memoKey = `${CampaignId}:${JSON.stringify(metrics)}`; + if (!eventMetricsMemo.hasOwnProperty(memoKey)) { + eventMetricsMemo[memoKey] = Object.entries(Metrics).every( + ([key, { ComparisonOperator, Value }]) => { + const compare = getComparator(ComparisonOperator); + // if there is some unknown comparison operator, treat as a comparison failure + return compare ? compare(Value, metrics[key]) : false; + } + ); + } + return eventMetricsMemo[memoKey]; +}; + +export const getComparator = (operator: string): MetricsComparator => { + switch (operator) { + case 'EQUAL': + return (metricsVal, eventVal) => metricsVal === eventVal; + case 'GREATER_THAN': + return (metricsVal, eventVal) => metricsVal < eventVal; + case 'GREATER_THAN_OR_EQUAL': + return (metricsVal, eventVal) => metricsVal <= eventVal; + case 'LESS_THAN': + return (metricsVal, eventVal) => metricsVal > eventVal; + case 'LESS_THAN_OR_EQUAL': + return (metricsVal, eventVal) => metricsVal >= eventVal; + default: + return null; + } +}; + +export const isBeforeEndDate = ({ + Schedule, +}: PinpointInAppMessage): boolean => { + if (!Schedule?.EndDate) { + return true; + } + return new Date() < new Date(Schedule.EndDate); +}; + +export const isQuietTime = (message: PinpointInAppMessage): boolean => { + const { Schedule } = message; + if (!Schedule?.QuietTime) { + return false; + } + + const pattern = /^[0-2]\d:[0-5]\d$/; // basic sanity check, not a fully featured HH:MM validation + const { Start, End } = Schedule.QuietTime; + if ( + !Start || + !End || + Start === End || + !pattern.test(Start) || + !pattern.test(End) + ) { + return false; + } + + const now = new Date(); + const start = new Date(now); + const end = new Date(now); + const [startHours, startMinutes] = Start.split(':'); + const [endHours, endMinutes] = End.split(':'); + + start.setHours( + Number.parseInt(startHours, 10), + Number.parseInt(startMinutes, 10), + 0, + 0 + ); + end.setHours( + Number.parseInt(endHours, 10), + Number.parseInt(endMinutes, 10), + 0, + 0 + ); + + // if quiet time includes midnight, bump the end time to the next day + if (start > end) { + end.setDate(end.getDate() + 1); + } + + const isQuietTime = now >= start && now <= end; + if (isQuietTime) { + logger.debug('message filtered due to quiet time', message); + } + return isQuietTime; +}; + +export const clearMemo = () => { + eventNameMemo = {}; + eventAttributesMemo = {}; + eventMetricsMemo = {}; +}; + +// in the pinpoint console when a message is created with a Modal or Full Screen layout, +// it is assigned a layout value of MOBILE_FEED or OVERLAYS respectively in the message payload. +// In the future, Pinpoint will be updating the layout values in the aforementioned scenario +// to MODAL and FULL_SCREEN. +// +// This utility acts as a safeguard to ensure that: +// - 1. the usage of MOBILE_FEED and OVERLAYS as values for message layouts are not leaked +// outside the Pinpoint provider +// - 2. Amplify correctly handles the legacy layout values from Pinpoint after they are updated +export const interpretLayout = ( + layout: PinpointInAppMessage['InAppMessage']['Layout'] +): InAppMessageLayout => { + if (layout === 'MOBILE_FEED') { + return 'MODAL'; + } + + if (layout === 'OVERLAYS') { + return 'FULL_SCREEN'; + } + + // cast as PinpointInAppMessage['InAppMessage']['Layout'] allows `string` as a value + return layout as InAppMessageLayout; +}; + +export const extractContent = ({ + InAppMessage: message, +}: PinpointInAppMessage): InAppMessageContent[] => { + return ( + message?.Content?.map(content => { + const { + BackgroundColor, + BodyConfig, + HeaderConfig, + ImageUrl, + PrimaryBtn, + SecondaryBtn, + } = content; + const defaultPrimaryButton = PrimaryBtn?.DefaultConfig; + const defaultSecondaryButton = SecondaryBtn?.DefaultConfig; + const extractedContent: InAppMessageContent = {}; + if (BackgroundColor) { + extractedContent.container = { + style: { + backgroundColor: BackgroundColor, + }, + }; + } + if (HeaderConfig) { + extractedContent.header = { + content: HeaderConfig.Header, + style: { + color: HeaderConfig.TextColor, + textAlign: + HeaderConfig.Alignment.toLowerCase() as InAppMessageTextAlign, + }, + }; + } + if (BodyConfig) { + extractedContent.body = { + content: BodyConfig.Body, + style: { + color: BodyConfig.TextColor, + textAlign: + BodyConfig.Alignment.toLowerCase() as InAppMessageTextAlign, + }, + }; + } + if (ImageUrl) { + extractedContent.image = { + src: ImageUrl, + }; + } + if (defaultPrimaryButton) { + extractedContent.primaryButton = { + title: defaultPrimaryButton.Text, + action: defaultPrimaryButton.ButtonAction as InAppMessageAction, + url: defaultPrimaryButton.Link, + style: { + backgroundColor: defaultPrimaryButton.BackgroundColor, + borderRadius: defaultPrimaryButton.BorderRadius, + color: defaultPrimaryButton.TextColor, + }, + }; + } + if (defaultSecondaryButton) { + extractedContent.secondaryButton = { + title: defaultSecondaryButton.Text, + action: defaultSecondaryButton.ButtonAction as InAppMessageAction, + url: defaultSecondaryButton.Link, + style: { + backgroundColor: defaultSecondaryButton.BackgroundColor, + borderRadius: defaultSecondaryButton.BorderRadius, + color: defaultSecondaryButton.TextColor, + }, + }; + } + return extractedContent; + }) ?? [] + ); +}; + +export const extractMetadata = ({ + InAppMessage, + Priority, + Schedule, + TreatmentId, +}: PinpointInAppMessage): InAppMessage['metadata'] => ({ + customData: InAppMessage?.CustomConfig, + endDate: Schedule?.EndDate, + priority: Priority, + treatmentId: TreatmentId, +}); diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/index.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/index.ts index 892e33cb4b7..d27d53e161f 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/index.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/index.ts @@ -10,3 +10,5 @@ export { CHANNEL_TYPE, STORAGE_KEY_SUFFIX, } from './constants'; + +export { processInAppMessages } from './processInAppMessages'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/processInAppMessages.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/processInAppMessages.ts new file mode 100644 index 00000000000..1207bcbede9 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/processInAppMessages.ts @@ -0,0 +1,144 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { InAppMessage, InAppMessagingEvent } from '../../../types'; +import { + InAppMessageCounts, + InAppMessageCountMap, + DailyInAppMessageCounter, +} from '../types'; +import { + extractContent, + extractMetadata, + interpretLayout, + isBeforeEndDate, + matchesAttributes, + matchesEventType, + matchesMetrics, +} from './helpers'; +import type { InAppMessageCampaign as PinpointInAppMessage } from '@aws-amplify/core/internals/aws-clients/pinpoint'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { defaultStorage } from '@aws-amplify/core'; + +const MESSAGE_DAILY_COUNT_KEY = 'pinpointProvider_inAppMessages_dailyCount'; +const MESSAGE_TOTAL_COUNT_KEY = 'pinpointProvider_inAppMessages_totalCount'; +const logger = new ConsoleLogger('InAppMessaging.processInAppMessages'); + +const sessionMessageCountMap: InAppMessageCountMap = {}; + +export async function processInAppMessages( + messages: PinpointInAppMessage[], + event: InAppMessagingEvent +): Promise { + let highestPrioritySeen: number; + let acc: PinpointInAppMessage[] = []; + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + const messageQualifies = + matchesEventType(message, event) && + matchesAttributes(message, event) && + matchesMetrics(message, event) && + isBeforeEndDate(message) && + (await isBelowCap(message)); + // filter all qualifying messages returning only those that are of (relative) highest priority + if (messageQualifies) { + // have not yet encountered message with priority + if (!highestPrioritySeen) { + // this message has priority, so reset the accumulator with this message only + if (message.Priority) { + highestPrioritySeen = message.Priority; + acc = [message]; + } else { + // this message also has no priority, so just add this message to accumulator + acc.push(message); + } + // have previously encountered message with priority, so only messages with priority matter now + } else if (message.Priority) { + // this message has higher priority (lower number), so reset the accumulator with this message only + if (message.Priority < highestPrioritySeen) { + highestPrioritySeen = message.Priority; + acc = [message]; + // this message has the same priority, so just add this message to accumulator + } else if (message.Priority === highestPrioritySeen) { + acc.push(message); + } + } + } + } + return normalizeMessages(acc); +} + +function normalizeMessages(messages: PinpointInAppMessage[]): InAppMessage[] { + return messages.map(message => { + const { CampaignId, InAppMessage } = message; + return { + id: CampaignId, + content: extractContent(message), + layout: interpretLayout(InAppMessage.Layout), + metadata: extractMetadata(message), + }; + }); +} + +async function isBelowCap({ + CampaignId, + SessionCap, + DailyCap, + TotalCap, +}: PinpointInAppMessage): Promise { + const { sessionCount, dailyCount, totalCount } = await getMessageCounts( + CampaignId + ); + return ( + (!SessionCap ?? sessionCount < SessionCap) && + (!DailyCap ?? dailyCount < DailyCap) && + (!TotalCap ?? totalCount < TotalCap) + ); +} + +async function getMessageCounts( + messageId: string +): Promise { + try { + return { + sessionCount: getSessionCount(messageId), + dailyCount: await getDailyCount(), + totalCount: await getTotalCount(messageId), + }; + } catch (err) { + logger.error('Failed to get message counts from storage', err); + } +} + +function getSessionCount(messageId: string): number { + return sessionMessageCountMap[messageId] || 0; +} + +async function getDailyCount(): Promise { + const today = getStartOfDay(); + const item = await defaultStorage.getItem(MESSAGE_DAILY_COUNT_KEY); + // Parse stored count or initialize as empty count + const counter: DailyInAppMessageCounter = item + ? JSON.parse(item) + : { count: 0, lastCountTimestamp: today }; + // If the stored counter timestamp is today, use it as the count, otherwise reset to 0 + return counter.lastCountTimestamp === today ? counter.count : 0; +} + +async function getTotalCountMap(): Promise { + const item = await defaultStorage.getItem(MESSAGE_TOTAL_COUNT_KEY); + // Parse stored count map or initialize as empty + return item ? JSON.parse(item) : {}; +} + +async function getTotalCount(messageId: string): Promise { + const countMap = await getTotalCountMap(); + // Return stored count or initialize as empty count + return countMap[messageId] || 0; +} + +const getStartOfDay = (): string => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.toISOString(); +}; diff --git a/packages/notifications/src/inAppMessaging/types/index.ts b/packages/notifications/src/inAppMessaging/types/index.ts index 9f1f00bb861..51a42ecab97 100644 --- a/packages/notifications/src/inAppMessaging/types/index.ts +++ b/packages/notifications/src/inAppMessaging/types/index.ts @@ -5,4 +5,10 @@ export { InAppMessagingServiceOptions } from './options'; export { InAppMessagingIdentifyUserInput } from './inputs'; export { InAppMessagingConfig } from './config'; export { InAppMessageInteractionEvent, InAppMessagingEvent } from './event'; -export { InAppMessage } from './message'; +export { + InAppMessage, + InAppMessageAction, + InAppMessageContent, + InAppMessageLayout, + InAppMessageTextAlign, +} from './message';