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",