Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/notifications-connect-customer-profiles-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@aws-amplify/core': minor
'@aws-amplify/notifications': minor
'aws-amplify': minor
---

feat(notifications): add Amazon Connect Customer Profiles push notifications provider

Push Notifications can now be delivered through Amazon Connect Customer Profiles via the new `aws-amplify/push-notifications/customer-profiles` sub-path export. The provider ships `identifyUser`, `initializePushNotifications`, `registerDevice`, and `removeDevice` alongside the transport-agnostic badge, permission, launch-notification, and notification/token listener APIs, with SigV4-signed device registration and client-side user-profile validation. `Amplify.configure` accepts the corresponding `amazon_connect` notifications configuration from `amplify_outputs.json`.

Device registration follows the signed-in principal: `initializePushNotifications` registers the device when a push token is received and re-registers it on sign-in so an existing registration is re-homed to the authenticated principal. Because de-registration is authorized against the calling principal, applications should await `removeDevice()` before `signOut()` to stop delivery to a device.

The default `aws-amplify/push-notifications` entry point emits a one-time `ConsoleLogger` notice at runtime directing customers to the Customer Profiles sub-path, since that entry point is backed by Amazon Pinpoint and AWS ends support for Amazon Pinpoint on October 30, 2026. Both changes are backwards compatible: existing exports keep their names, types, and signatures.
9 changes: 9 additions & 0 deletions packages/aws-amplify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@
"import": "./dist/esm/push-notifications/pinpoint/index.mjs",
"require": "./dist/cjs/push-notifications/pinpoint/index.js"
},
"./push-notifications/customer-profiles": {
"react-native": "./dist/cjs/push-notifications/customer-profiles/index.js",
"types": "./dist/esm/push-notifications/customer-profiles/index.d.ts",
"import": "./dist/esm/push-notifications/customer-profiles/index.mjs",
"require": "./dist/cjs/push-notifications/customer-profiles/index.js"
},
"./adapter-core": {
"types": "./dist/esm/adapter-core/index.d.ts",
"import": "./dist/esm/adapter-core/index.mjs",
Expand Down Expand Up @@ -239,6 +245,9 @@
"push-notifications/pinpoint": [
"./dist/esm/push-notifications/pinpoint/index.d.ts"
],
"push-notifications/customer-profiles": [
"./dist/esm/push-notifications/customer-profiles/index.d.ts"
],
"adapter-core": [
"./dist/esm/adapter-core/index.d.ts"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "aws-amplify/push-notifications/customer-profiles",
"main": "../../dist/cjs/push-notifications/customer-profiles/index.js",
"react-native": "../../dist/cjs/push-notifications/customer-profiles/index.js",
"browser": "../../dist/esm/push-notifications/customer-profiles/index.mjs",
"module": "../../dist/esm/push-notifications/customer-profiles/index.mjs",
"typings": "../../dist/esm/push-notifications/customer-profiles/index.d.ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

/*
This file maps exports from `aws-amplify/push-notifications/customer-profiles`.
It provides access to the Amazon Connect Customer Profiles provider of the PushNotification sub-category.
*/
export * from '@aws-amplify/notifications/push-notifications/customer-profiles';
55 changes: 55 additions & 0 deletions packages/core/__tests__/parseAmplifyOutputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable camelcase */
import { AmplifyOutputs, parseAmplifyOutputs } from '../src/libraryUtils';

Check warning on line 2 in packages/core/__tests__/parseAmplifyOutputs.test.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/core

Deprecated: This type is deprecated and will be removed in future versions

Check warning on line 2 in packages/core/__tests__/parseAmplifyOutputs.test.ts

View workflow job for this annotation

GitHub Actions / release-verification / unit-tests / Unit Test - @aws-amplify/core

Deprecated: This type is deprecated and will be removed in future versions
import mockAmplifyOutputs from '../__mocks__/configMocks/amplify_outputs.json';

describe('parseAmplifyOutputs tests', () => {
Expand Down Expand Up @@ -560,6 +560,61 @@
},
});
});

it('should configure Pinpoint and Customer Profiles push notifications together', () => {
const amplifyOutputs: AmplifyOutputs = {
version: '1',
notifications: {
aws_region: 'us-west-2',
amazon_pinpoint_app_id: 'appid123',
channels: ['APNS', 'FCM'],
amazon_connect: {
endpoint: 'https://example.com/prod',
aws_region: 'us-east-1',
},
},
};

const result = parseAmplifyOutputs(amplifyOutputs);
expect(result).toEqual({
Notifications: {
PushNotification: {
Pinpoint: {
appId: 'appid123',
region: 'us-west-2',
},
CustomerProfiles: {
endpoint: 'https://example.com/prod',
region: 'us-east-1',
},
},
},
});
});

it('should configure Customer Profiles push notifications without Pinpoint channels', () => {
const amplifyOutputs: AmplifyOutputs = {
version: '1',
notifications: {
amazon_connect: {
endpoint: 'https://example.com/prod',
aws_region: 'us-east-1',
},
},
};

const result = parseAmplifyOutputs(amplifyOutputs);
expect(result).toEqual({
Notifications: {
PushNotification: {
CustomerProfiles: {
endpoint: 'https://example.com/prod',
region: 'us-east-1',
},
},
},
});
});
});
});
});
2 changes: 2 additions & 0 deletions packages/core/src/Platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export enum PubSubAction {
export enum PushNotificationAction {
InitializePushNotifications = '1',
IdentifyUser = '2',
RegisterDevice = '3',
RemoveDevice = '4',
}
export enum StorageAction {
UploadData = '1',
Expand Down
40 changes: 29 additions & 11 deletions packages/core/src/parseAmplifyOutputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
PreferredChallenge,
} from './singleton/Auth/types';
import { NotificationsConfig } from './singleton/Notifications/types';
import { PushNotificationConfig } from './singleton/Notifications/PushNotification/types';
import {
AmplifyOutputsAnalyticsProperties,
AmplifyOutputsAuthProperties,
Expand Down Expand Up @@ -272,21 +273,23 @@ function parseNotifications(
return undefined;
}

const { aws_region, channels, amazon_pinpoint_app_id } =
const { aws_region, channels, amazon_pinpoint_app_id, amazon_connect } =
amplifyOutputsNotificationsProperties;

const hasInAppMessaging = channels.includes('IN_APP_MESSAGING');
const supportedChannels = channels ?? [];
const hasInAppMessaging = supportedChannels.includes('IN_APP_MESSAGING');
const hasPushNotification =
channels.includes('APNS') || channels.includes('FCM');
supportedChannels.includes('APNS') || supportedChannels.includes('FCM');
const hasCustomerProfilesPush = !!amazon_connect;

if (!(hasInAppMessaging || hasPushNotification)) {
if (!(hasInAppMessaging || hasPushNotification || hasCustomerProfilesPush)) {
return undefined;
}

// At this point, we know the Amplify outputs contains at least one supported channel
const notificationsConfig: NotificationsConfig = {} as NotificationsConfig;

if (hasInAppMessaging) {
if (hasInAppMessaging && amazon_pinpoint_app_id && aws_region) {
notificationsConfig.InAppMessaging = {
Pinpoint: {
appId: amazon_pinpoint_app_id,
Expand All @@ -295,15 +298,30 @@ function parseNotifications(
};
}

if (hasPushNotification) {
notificationsConfig.PushNotification = {
Pinpoint: {
appId: amazon_pinpoint_app_id,
region: aws_region,
},
// Push device registration can be backed by Amazon Pinpoint and/or Amazon
// Connect Customer Profiles. Each provider is emitted independently when its
// configuration is present, mirroring how analytics is parsed.
const pushNotificationConfig: Partial<PushNotificationConfig> = {};

if (hasPushNotification && amazon_pinpoint_app_id && aws_region) {
pushNotificationConfig.Pinpoint = {
appId: amazon_pinpoint_app_id,
region: aws_region,
};
}

if (amazon_connect) {
pushNotificationConfig.CustomerProfiles = {
endpoint: amazon_connect.endpoint,
region: amazon_connect.aws_region,
};
}

if (Object.keys(pushNotificationConfig).length > 0) {
notificationsConfig.PushNotification =
pushNotificationConfig as PushNotificationConfig;
}

return notificationsConfig;
}

Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/singleton/AmplifyOutputs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,13 @@ export interface AmplifyOutputsCustomProperties {
}

export interface AmplifyOutputsNotificationsProperties {
aws_region: string;
amazon_pinpoint_app_id: string;
channels: string[];
aws_region?: string;
amazon_pinpoint_app_id?: string;
channels?: string[];
amazon_connect?: {
aws_region: string;
endpoint: string;
};
}

/** @deprecated This type is deprecated and will be removed in future versions. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,21 @@
// SPDX-License-Identifier: Apache-2.0

import { PinpointProviderConfig } from '../../../providers/pinpoint/types';
import { AtLeastOne } from '../../types';

export type PushNotificationConfig = PinpointProviderConfig;
/**
* Configuration for the Amazon Connect Customer Profiles Push Notification
* provider. The provider backs device registration (`identifyUser` /
* `initializePushNotifications`) with a REST endpoint (typically fronted by a
* Lambda) that writes device tokens to Amazon Connect Customer Profiles.
*/
export interface ConnectCustomerProfilesPushProviderConfig {
CustomerProfiles: {
endpoint: string;
region: string;
};
}

export type PushNotificationConfig = AtLeastOne<
PinpointProviderConfig & ConnectCustomerProfilesPushProviderConfig
>;
59 changes: 59 additions & 0 deletions packages/notifications/__tests__/pushNotifications/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { ConsoleLogger } from '@aws-amplify/core';

import * as defaultExports from '../../src/pushNotifications';
import * as customerProfilesExports from '../../src/pushNotifications/providers/customer-profiles';

const DEPRECATED_RUNTIME_APIS = [
'getBadgeCount',
'setBadgeCount',
'getPermissionStatus',
'requestPermissions',
'getLaunchNotification',
'onNotificationReceivedInForeground',
'onNotificationReceivedInBackground',
'onNotificationOpened',
'onTokenReceived',
'identifyUser',
'initializePushNotifications',
] as const;

describe('push-notifications default (Pinpoint) entry point', () => {
const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn');

beforeEach(() => {
loggerWarnSpy.mockClear();
});

it.each(DEPRECATED_RUNTIME_APIS)(
'emits a deprecation warning when %s is invoked',
async apiName => {
const api = defaultExports[apiName] as (...args: any[]) => unknown;

// Every default API is unsupported on the web platform, so invoking it
// throws synchronously or rejects. The warning must still be emitted
// beforehand in both cases, and the underlying error must reach the
// caller untouched.
try {
await api({ handler: () => undefined });
throw new Error(`expected ${apiName} to reject or throw`);
} catch (error) {
expect((error as Error).name).toBe('PlatformNotSupported');
}

expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'aws-amplify/push-notifications/customer-profiles',
),
);
},
);

it('exposes an equivalent for every deprecated API on the customer-profiles sub-path', () => {
DEPRECATED_RUNTIME_APIS.forEach(apiName => {
expect(typeof customerProfilesExports[apiName]).toBe('function');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { identifyUser } from '../../../../../src/pushNotifications/providers/customer-profiles/apis/identifyUser.native';
import { identifyUserInternal } from '../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal';
import { IdentifyUserInput } from '../../../../../src/pushNotifications/providers/customer-profiles/types';

jest.mock(
'../../../../../src/pushNotifications/providers/customer-profiles/utils/identifyUserInternal',
);

describe('identifyUser (customer-profiles, native)', () => {
const mockIdentifyUserInternal = identifyUserInternal as jest.Mock;

beforeEach(() => {
mockIdentifyUserInternal.mockResolvedValue(undefined);
});

afterEach(() => {
mockIdentifyUserInternal.mockReset();
});

it('performs a profile-only identify and registers NO device', async () => {
const input: IdentifyUserInput = {
userProfile: {
email: 'email',
name: 'name',
customAttributes: { hobby: 'biking' },
},
};
await identifyUser(input);

expect(mockIdentifyUserInternal).toHaveBeenCalledTimes(1);
expect(mockIdentifyUserInternal).toHaveBeenCalledWith({
userProfile: input.userProfile,
});
const call = mockIdentifyUserInternal.mock.calls[0][0];
// native identify no longer registers a device or sends a userId
expect(call).not.toHaveProperty('userId');
expect(call).not.toHaveProperty('deviceToken');
expect(call).not.toHaveProperty('channelType');
expect(call).not.toHaveProperty('options');
});

it('forwards a minimal input (empty userProfile)', async () => {
await identifyUser({ userProfile: {} });

expect(mockIdentifyUserInternal).toHaveBeenCalledWith({ userProfile: {} });
});

it('rejects if the identify request rejects', async () => {
mockIdentifyUserInternal.mockRejectedValue(new Error('service error'));
await expect(identifyUser({ userProfile: {} })).rejects.toThrow(
'service error',
);
});
});
Loading
Loading