Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions mpapi-next-gen/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions mpapi-next-gen/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions mpapi-next-gen/src/common/ssmParameters.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -31,3 +32,15 @@ export async function getParameterValue(
throw error;
}
}

export async function getParameterValueUsingAppStageStackConvention<A>(
key: string,
): Promise<A> {
// This function was introduced to help migrating the old
// getConfigValue<A>(key: string, defaultValue?: A): Promise<A>
// 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;
}
205 changes: 205 additions & 0 deletions mpapi-next-gen/src/lambdas/deleteUserSubscription.ts
Original file line number Diff line number Diff line change
@@ -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<UserSubscription[]> {
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<number> {
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<HandlerResponse> {
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,
};
}
2 changes: 1 addition & 1 deletion mpapi-next-gen/src/lambdas/googleOauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
1 change: 0 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading