diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index d5cb5f89..602e183e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -77,7 +77,7 @@ jobs:
mobile-purchases-apple-update-subscriptions:
- tsc-target/apple-update-subscriptions.zip
mobile-purchases-delete-user-subscription:
- - tsc-target/delete-user-subscription.zip
+ - mpapi-next-gen/dist/delete-user-subscription.zip
mobile-purchases-export-historical-data:
- mpapi-next-gen/dist/export-historical-data.zip
mobile-purchases-export-subscription-events-table:
diff --git a/mpapi-next-gen/build.sh b/mpapi-next-gen/build.sh
index 578956d7..d31633c7 100644
--- a/mpapi-next-gen/build.sh
+++ b/mpapi-next-gen/build.sh
@@ -11,6 +11,7 @@ cd dist
# maintenance: please maintain alphabetical order
HANDLERS=(
+ "delete-user-subscription"
"export-historical-data"
"export-subscription-events-table"
"export-subscription-table-v2"
diff --git a/mpapi-next-gen/esbuild.config.js b/mpapi-next-gen/esbuild.config.js
index 8e5ad94f..d6dac40a 100644
--- a/mpapi-next-gen/esbuild.config.js
+++ b/mpapi-next-gen/esbuild.config.js
@@ -2,6 +2,7 @@ const esbuild = require('esbuild');
// maintenance: please maintain alphabetical order
const entryPoints = {
+ 'delete-user-subscription': './src/lambdas/deleteUserSubscription.ts',
'export-historical-data': './src/lambdas/exportHistoricalData.ts',
'export-subscription-events-table':
'./src/lambdas/exportSubscriptionEventsTable.ts',
diff --git a/mpapi-next-gen/src/common/ssmParameters.ts b/mpapi-next-gen/src/common/ssmParameters.ts
index 4dae3e59..882c530c 100644
--- a/mpapi-next-gen/src/common/ssmParameters.ts
+++ b/mpapi-next-gen/src/common/ssmParameters.ts
@@ -1,4 +1,5 @@
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
+import { App, Stack, Stage } from './appIdentity';
const ssmClient = new SSMClient({
region: process.env.AWS_REGION || 'us-east-1',
@@ -31,3 +32,15 @@ export async function getParameterValue(
throw error;
}
}
+
+export async function getParameterValueUsingAppStageStackConvention(
+ key: string,
+): Promise {
+ // This function was introduced to help migrating the old
+ // getConfigValue(key: string, defaultValue?: A): Promise
+ // from the legacy code.
+ // We will get rid of it later on.
+ const parameterName = `/${App}/${Stage}/${Stack}/${key}`;
+ const value = await getParameterValue(parameterName);
+ return value as A;
+}
diff --git a/mpapi-next-gen/src/lambdas/deleteUserSubscription.ts b/mpapi-next-gen/src/lambdas/deleteUserSubscription.ts
new file mode 100644
index 00000000..2817eac2
--- /dev/null
+++ b/mpapi-next-gen/src/lambdas/deleteUserSubscription.ts
@@ -0,0 +1,205 @@
+import 'source-map-support/register';
+import type { DynamoDBStreamEvent } from 'aws-lambda';
+import { UserSubscription } from '../common/userSubscription';
+import { Region, Stage } from '../common/appIdentity';
+import { getMembershipAccountId } from '../common/guIdentityApi';
+import { mapPlatformToSoftOptInProductName } from '../common/softOptIns';
+import {
+ DynamoDBClient,
+ QueryCommand,
+ DeleteItemCommand,
+} from '@aws-sdk/client-dynamodb';
+import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
+import {
+ CloudWatchClient,
+ PutMetricDataCommand,
+} from '@aws-sdk/client-cloudwatch';
+
+const dynamoClient = new DynamoDBClient({ region: Region });
+const sqsClient = new SQSClient({ region: Region });
+const cloudWatchClient = new CloudWatchClient({ region: Region });
+
+const userSubscriptionTableName = UserSubscription.getTableName();
+
+async function handleSoftOptInsError(message: string) {
+ console.error(message);
+ await putMetric('failed_to_send_cancellation_message', 1);
+}
+
+async function getUserLinks(
+ subscriptionId: string,
+): Promise {
+ const userLinks: UserSubscription[] = [];
+
+ const command = new QueryCommand({
+ TableName: userSubscriptionTableName,
+ IndexName: 'subscriptionId-userId',
+ KeyConditionExpression: 'subscriptionId = :subscriptionId',
+ ExpressionAttributeValues: {
+ ':subscriptionId': { S: subscriptionId },
+ },
+ });
+
+ const response = await dynamoClient.send(command);
+
+ if (response.Items) {
+ for (const item of response.Items) {
+ const userSubscription = new UserSubscription(
+ item.userId?.S || '',
+ item.subscriptionId?.S || '',
+ item.creationTimestamp?.S || '',
+ );
+ userLinks.push(userSubscription);
+ }
+ }
+
+ return userLinks;
+}
+
+async function putMetric(metricName: string, value: number) {
+ try {
+ const command = new PutMetricDataCommand({
+ Namespace: 'MobilePurchases',
+ MetricData: [
+ {
+ MetricName: metricName,
+ Value: value,
+ Unit: 'Count',
+ },
+ ],
+ });
+ await cloudWatchClient.send(command);
+ } catch (error) {
+ console.error(`Failed to put metric ${metricName}:`, error);
+ }
+}
+
+async function deleteUserSubscription(
+ userLinks: UserSubscription[],
+): Promise {
+ let count = 0;
+ const tableName = UserSubscription.getTableName();
+
+ for (const userLink of userLinks) {
+ try {
+ const command = new DeleteItemCommand({
+ TableName: tableName,
+ Key: {
+ userId: { S: userLink.userId },
+ subscriptionId: { S: userLink.subscriptionId },
+ },
+ });
+ await dynamoClient.send(command);
+ count++;
+ } catch (error) {
+ console.error(`Failed to delete user link:`, error);
+ }
+ }
+
+ if (userLinks.length !== count) {
+ console.warn(`Queried ${userLinks.length} rows, but only deleted ${count}`);
+ }
+
+ console.log(`Deleted ${count} rows`);
+ return count;
+}
+
+async function sendToSqsSoftOptIns(queueUrl: string, messageBody: unknown) {
+ const command = new SendMessageCommand({
+ QueueUrl: queueUrl,
+ MessageBody: JSON.stringify(messageBody),
+ });
+ await sqsClient.send(command);
+}
+
+async function disableSoftOptIns(
+ userLinks: UserSubscription[],
+ subscriptionId: string,
+ platform: string | undefined,
+) {
+ const membershipAccountId = await getMembershipAccountId();
+ const queueNamePrefix = `https://sqs.${Region}.amazonaws.com/${membershipAccountId}`;
+
+ const user = userLinks[0];
+
+ await sendToSqsSoftOptIns(
+ Stage === 'PROD'
+ ? `${queueNamePrefix}/soft-opt-in-consent-setter-queue-PROD`
+ : `${queueNamePrefix}/soft-opt-in-consent-setter-queue-CODE`,
+ {
+ identityId: user.userId,
+ eventType: 'Cancellation',
+ productName: mapPlatformToSoftOptInProductName(platform),
+ subscriptionId: subscriptionId,
+ },
+ );
+ console.log(`sent soft opt-in message for identityId ${user.userId}`);
+}
+
+interface HandlerResponse {
+ recordCount: number;
+ rowCount: number;
+}
+
+export async function handler(
+ event: DynamoDBStreamEvent,
+): Promise {
+ const ttlEvents = event.Records.filter((dynamoEvent) => {
+ return (
+ dynamoEvent.eventName === 'REMOVE' &&
+ dynamoEvent.userIdentity?.type === 'Service' &&
+ dynamoEvent.userIdentity?.principalId === 'dynamodb.amazonaws.com' &&
+ dynamoEvent.dynamodb?.OldImage?.subscriptionId?.S
+ );
+ });
+
+ const subscriptions = ttlEvents.map((event) => event.dynamodb?.OldImage);
+
+ let recordCount = 0;
+ let rowCount = 0;
+ let softOptInSuccessCount = 0;
+
+ for (const subscription of subscriptions) {
+ const subscriptionId = subscription?.subscriptionId?.S;
+
+ if (!subscriptionId) {
+ console.warn(`Skipping: Missing subscriptionId in subscription object`);
+ continue;
+ }
+
+ const userSubscriptions = await getUserLinks(subscriptionId);
+
+ if (userSubscriptions.length === 0) {
+ console.log(
+ `No user links to delete for subscriptionId: ${subscriptionId}`,
+ );
+ } else {
+ rowCount += await deleteUserSubscription(userSubscriptions);
+
+ try {
+ const platform = subscription?.platform?.S;
+ await disableSoftOptIns(userSubscriptions, subscriptionId, platform);
+ softOptInSuccessCount++;
+ } catch (e) {
+ await handleSoftOptInsError(
+ `Soft opt-in message send failed for subscriptionId: ${subscriptionId}. ${e}`,
+ );
+ }
+ }
+
+ recordCount++;
+ }
+
+ console.log(
+ `Processed ${recordCount} records from dynamo stream to delete ${rowCount} rows`,
+ );
+
+ console.log(
+ `Processed ${recordCount} records from dynamo stream to disable soft opt-ins for ${softOptInSuccessCount} users`,
+ );
+
+ return {
+ recordCount,
+ rowCount,
+ };
+}
diff --git a/mpapi-next-gen/src/lambdas/googleOauth2.ts b/mpapi-next-gen/src/lambdas/googleOauth2.ts
index 3d74d266..9ee0259f 100644
--- a/mpapi-next-gen/src/lambdas/googleOauth2.ts
+++ b/mpapi-next-gen/src/lambdas/googleOauth2.ts
@@ -92,6 +92,6 @@ export const handler = async (_event: APIGatewayProxyEvent) => {
console.log(`File uploaded successfully: ${locationKey}`);
console.log('ETag:', response.ETag);
} catch (error) {
- console.error('Error uploading `${locationKey}` to S3:', error);
+ console.error('[8e446120] error uploading `${locationKey}` to S3:', error);
}
};
diff --git a/webpack.config.js b/webpack.config.js
index ba113a66..830a0c66 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -9,7 +9,6 @@ const getEntries = (env) => {
"apple-fetch-offer-details": "./typescript/src/promotional-offers/appleFetchOfferDetails.ts",
"google-link-user-subscription": "./typescript/src/link/google.ts",
"apple-link-user-subscription": "./typescript/src/link/apple.ts",
- "delete-user-subscription": "./typescript/src/link/deleteLink.ts",
"user-subscriptions": "./typescript/src/user/user.ts",
"google-update-subscriptions": "./typescript/src/update-subs/google.ts",
"apple-update-subscriptions": "./typescript/src/update-subs/apple.ts",