diff --git a/cdk/lib/__snapshots__/dotcom-components.test.ts.snap b/cdk/lib/__snapshots__/dotcom-components.test.ts.snap index 167308b79..4d822e776 100644 --- a/cdk/lib/__snapshots__/dotcom-components.test.ts.snap +++ b/cdk/lib/__snapshots__/dotcom-components.test.ts.snap @@ -20,6 +20,8 @@ exports[`The DotcomComponents stack matches the snapshot 1`] = ` "GuDynamoDBReadPolicy", "GuDynamoDBReadPolicy", "GuDynamoDBReadPolicy", + "GuDynamoDBReadPolicy", + "GuDynamoDBWritePolicy", "GuAllowPolicy", "GuVpcParameter", "GuSubnetListParameter", @@ -338,6 +340,125 @@ exports[`The DotcomComponents stack matches the snapshot 1`] = ` }, "Type": "AWS::CloudWatch::Alarm", }, + "BrazeDynamoReadPolicy240BA703": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:GetRecords", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:aws:dynamodb:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":table/braze-messages-TEST", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:aws:dynamodb:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":table/braze-messages-TEST/index/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "BrazeDynamoReadPolicy240BA703", + "Roles": [ + { + "Ref": "InstanceRoleDotcomcomponents2E8FDE7D", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "BrazeDynamoWritePolicy0A655592": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:aws:dynamodb:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":table/braze-messages-TEST", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:aws:dynamodb:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":table/braze-messages-TEST/index/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "BrazeDynamoWritePolicy0A655592", + "Roles": [ + { + "Ref": "InstanceRoleDotcomcomponents2E8FDE7D", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, "CertificateDotcomcomponents88C2E1C7": { "DeletionPolicy": "Retain", "Properties": { diff --git a/cdk/lib/dotcom-components.ts b/cdk/lib/dotcom-components.ts index 251f2adb5..77557bc2d 100644 --- a/cdk/lib/dotcom-components.ts +++ b/cdk/lib/dotcom-components.ts @@ -11,6 +11,7 @@ import { import { GuAllowPolicy, GuDynamoDBReadPolicy, + GuDynamoDBWritePolicy, GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from '@guardian/cdk/lib/constructs/iam'; @@ -241,6 +242,12 @@ sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon- new GuDynamoDBReadPolicy(this, 'DynamoBanditReadPolicy', { tableName: `support-bandit-${this.stage}`, }), + new GuDynamoDBReadPolicy(this, 'BrazeDynamoReadPolicy', { + tableName: `braze-messages-${this.stage}`, + }), + new GuDynamoDBWritePolicy(this, 'BrazeDynamoWritePolicy', { + tableName: `braze-messages-${this.stage}`, + }), new GuAllowPolicy(this, 'SSMGet', { actions: ['ssm:GetParameter'], resources: ['*'], @@ -262,7 +269,7 @@ sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon- }, unhealthyInstancesAlarm: true, snsTopicName, - } + } : { noMonitoring: true }; const ec2App = new GuEc2App(this, { diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index e346478c0..6ac225420 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -13,6 +13,9 @@ import type { WeeklyArticleLog, } from '../../shared/types'; import { hideSRMessagingForInfoPageIds } from '../../shared/types'; +import type { BrazeEpicTest } from '../braze/brazeEpic'; +import { brazeEpicSchema, transformBrazeEpic } from '../braze/brazeEpic'; +import { addBrazeEpicTest, removeBrazeEpicTest } from '../braze/brazeTable'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; import { baseUrl } from '../lib/env'; @@ -26,7 +29,7 @@ import { selectAmountsTestVariant } from '../selection/ab'; import type { BanditData } from '../selection/banditData'; import type { Debug } from '../tests/epics/epicSelection'; import { findForcedTestAndVariant, findTestAndVariant } from '../tests/epics/epicSelection'; -import { logWarn } from '../utils/logging'; +import { logInfo, logWarn } from '../utils/logging'; import type { ValueProvider } from '../utils/valueReloader'; interface EpicDataResponse { @@ -52,6 +55,8 @@ export const buildEpicRouter = ( choiceCardAmounts: ValueProvider, tickerData: TickerDataProvider, banditData: ValueProvider, + brazeWebhookApiKey: string, + brazeCustomEventApiKey: string, ): Router => { const router = Router(); @@ -75,18 +80,73 @@ export const buildEpicRouter = ( } }; + const getBrazeMessage = ( + clientTagIds: string[], + brazeTests: BrazeEpicTest[], + ): BrazeEpicTest | undefined => + brazeTests.find( + (test) => + test.tagIds.length === 0 || + test.tagIds.some((tagId) => clientTagIds.includes(tagId)), + ); + const buildEpicData = ( targeting: EpicTargeting, type: EpicType, params: Params, baseUrl: string, req: express.Request, + res: express.Response, ): EpicDataResponse => { const { enableEpics, enableSuperMode, enableHardcodedEpicTests } = channelSwitches.get(); if (!enableEpics) { return {}; } + const brazeTest = getBrazeMessage( + targeting.tags.map((tag) => tag.id), + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- TODO + res.locals.brazeMessages ?? [], + ); + + if (brazeTest) { + const testTracking: TestTracking = { + abTestName: brazeTest.testName, + abTestVariant: brazeTest.variantName, + campaignCode: `${brazeTest.testName}_${brazeTest.variantName}`, + campaignId: `${brazeTest.testName}_${brazeTest.variantName}`, + componentType: 'ACQUISITIONS_EPIC', + products: ['CONTRIBUTION', 'MEMBERSHIP_SUPPORTER'], + // @ts-expect-error -- TODO + brazeMessageIdentifier: `${brazeTest.testName}:${brazeTest.variantName}`, + }; + + const props: EpicProps = { + variant: { + name: brazeTest.testName, + heading: brazeTest.heading, + paragraphs: brazeTest.paragraphs, + highlightedText: brazeTest.highlightedText, + cta: brazeTest.cta, + }, + articleCounts: { for52Weeks: 0, forTargetedWeeks: 0 }, + countryCode: targeting.countryCode, + tracking: testTracking as Tracking, + }; + + return { + data: { + variant: props.variant, + meta: testTracking, + module: { + name: + type === 'ARTICLE' ? 'ContributionsEpic' : 'ContributionsLiveblogEpic', + props, + }, + }, + }; + } + if (hideSRMessagingForInfoPageIds(targeting)) { return {}; } @@ -179,7 +239,7 @@ export const buildEpicRouter = ( const { targeting } = req.body; const params = getQueryParams(req.query); - const response = buildEpicData(targeting, epicType, params, baseUrl(req), req); + const response = buildEpicData(targeting, epicType, params, baseUrl(req), req, res); // for response logging res.locals.didRenderEpic = !!response.data; @@ -209,7 +269,7 @@ export const buildEpicRouter = ( const { targeting } = req.body; const params = getQueryParams(req.query); - const response = buildEpicData(targeting, epicType, params, baseUrl(req), req); + const response = buildEpicData(targeting, epicType, params, baseUrl(req), req, res); // for response logging res.locals.didRenderEpic = !!response.data; @@ -226,5 +286,69 @@ export const buildEpicRouter = ( }, ); + router.post('/braze/epic', async (req: express.Request, res: express.Response) => { + // No need for CORS here, this endpoint is requested server-to-server + res.removeHeader('Access-Control-Allow-Origin'); + + if (req.header('X-Api-Key') !== brazeWebhookApiKey) { + res.status(401); + res.send(); + return; + } + + const parseResult = brazeEpicSchema.safeParse(req.body); + + if (!parseResult.success) { + res.status(400); + res.send(parseResult.error); + return; + } + + const liveblogEpic = parseResult.data; + const message: BrazeEpicTest = transformBrazeEpic(liveblogEpic); + + await addBrazeEpicTest(liveblogEpic.brazeUUID, message); + logInfo(JSON.stringify(message)); + + res.status(201); + res.send(); + }); + + router.post('/braze/epic/event', async (req: express.Request, res: express.Response) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- TODO + const { brazeMessageIdentifier, brazeUUID, eventType } = req.body; + if (brazeUUID && brazeMessageIdentifier && eventType) { + const testName = (brazeMessageIdentifier as string).split(':')[0]; + await removeBrazeEpicTest(brazeUUID as string, testName); + + logInfo( + `Sending event to braze: epic_${eventType}, ${brazeMessageIdentifier as string}`, + ); + await fetch('https://rest.fra-01.braze.eu/users/track', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${brazeCustomEventApiKey}`, + }, + body: JSON.stringify({ + events: [ + { + external_id: brazeUUID as string, + name: `epic_${eventType}`, + properties: { + id: brazeMessageIdentifier as string, + }, + time: new Date().toISOString(), + }, + ], + }), + }); + res.status(200); + } else { + res.status(400); + } + res.send(); + }); + return router; }; diff --git a/src/server/braze/brazeEpic.ts b/src/server/braze/brazeEpic.ts new file mode 100644 index 000000000..640e8cd3d --- /dev/null +++ b/src/server/braze/brazeEpic.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import type { Cta } from '../../shared/types'; + +export interface BrazeEpicTest { + testName: string; + variantName: string; + tagIds: string[]; + heading?: string; + paragraphs: string[]; + highlightedText?: string; + cta: Cta; +} + +export const brazeEpicSchema = z.object({ + brazeUUID: z.string(), + testName: z.string(), + variantName: z.string(), + tagIds: z.string(), + heading: z.string().optional(), + paragraphs: z.string(), + ctaText: z.string(), + ctaBaseUrl: z.string(), + highlightedText: z.string().optional(), +}); + +export type BrazeEpic = z.infer; + +export const transformBrazeEpic = (liveblogEpic: BrazeEpic): BrazeEpicTest => ({ + testName: liveblogEpic.testName, + variantName: liveblogEpic.variantName, + heading: liveblogEpic.heading, + highlightedText: liveblogEpic.highlightedText, + paragraphs: liveblogEpic.paragraphs.split('|'), + tagIds: liveblogEpic.tagIds.split('|'), + cta: { + text: liveblogEpic.ctaText, + baseUrl: liveblogEpic.ctaBaseUrl, + }, +}); diff --git a/src/server/braze/brazeTable.ts b/src/server/braze/brazeTable.ts new file mode 100644 index 000000000..643061839 --- /dev/null +++ b/src/server/braze/brazeTable.ts @@ -0,0 +1,59 @@ +import * as AWS from 'aws-sdk'; +import { isProd } from '../lib/env'; +import { logError, logInfo } from '../utils/logging'; +import type { BrazeEpicTest } from './brazeEpic'; + +const stage = isProd ? 'PROD' : 'CODE'; +const getDocClient = () => new AWS.DynamoDB.DocumentClient({ region: 'eu-west-1' }); +const getTableName = (stage: string) => `braze-messages-${stage.toUpperCase()}`; + +export const fetchBrazeEpicTests = async (brazeUUID: string): Promise => { + const docClient = getDocClient(); + + const tests = await docClient + .query({ + TableName: getTableName(stage), + KeyConditionExpression: 'brazeUUID = :brazeUUID', + ExpressionAttributeValues: { + ':brazeUUID': brazeUUID, + }, + }) + .promise() + .then((result) => result.Items ?? []) + .catch((error) => { + logError(`Error fetching braze epic tests from dynamo: ${error}`); + return []; + }); + + // eslint-disable-next-line @typescript-eslint/no-base-to-string,@typescript-eslint/restrict-template-expressions -- no + logInfo(`Got braze epic tests: ${tests}`); + + return tests as BrazeEpicTest[]; +}; + +export const addBrazeEpicTest = async (brazeUUID: string, test: BrazeEpicTest): Promise => { + const docClient = getDocClient(); + + return docClient + .put({ + TableName: getTableName(stage), + Item: { brazeUUID, ...test }, + }) + .promise() + .then(() => undefined); +}; + +export const removeBrazeEpicTest = async (brazeUUID: string, testName: string): Promise => { + const docClient = getDocClient(); + + return docClient + .delete({ + TableName: getTableName(stage), + Key: { + brazeUUID, + testName, + }, + }) + .promise() + .then(() => undefined); +}; diff --git a/src/server/middleware/brazeMessagesMiddleware.ts b/src/server/middleware/brazeMessagesMiddleware.ts new file mode 100644 index 000000000..0a5166c7b --- /dev/null +++ b/src/server/middleware/brazeMessagesMiddleware.ts @@ -0,0 +1,18 @@ +import type express from 'express'; +import { fetchBrazeEpicTests } from '../braze/brazeTable'; + +export const brazeMessagesMiddleware = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access -- expected + const brazeUUID = req.body?.targeting?.brazeUUID; + if (brazeUUID) { + const tests = await fetchBrazeEpicTests(brazeUUID as string); + if (tests.length > 0) { + res.locals.brazeMessages = tests; + } + } + next(); +}; diff --git a/src/server/server.ts b/src/server/server.ts index 0bb5cc95b..3cdfc839e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -17,6 +17,7 @@ import { errorHandling as errorHandlingMiddleware, logging as loggingMiddleware, } from './middleware'; +import { brazeMessagesMiddleware } from './middleware/brazeMessagesMiddleware'; import { buildProductPricesReloader } from './productPrices'; import { buildBanditDataReloader } from './selection/banditData'; import { buildAmpEpicTestsReloader } from './tests/amp/ampEpicTests'; @@ -27,6 +28,7 @@ import { buildEpicLiveblogTestsReloader, buildEpicTestsReloader } from './tests/ import { buildGutterLiveblogTestsReloader } from './tests/gutters/gutterTests'; import { buildHeaderTestsReloader } from './tests/headers/headerTests'; import { logError } from './utils/logging'; +import { getSsmValue } from './utils/ssm'; const buildApp = async (): Promise => { const app = express(); @@ -57,6 +59,7 @@ const buildApp = async (): Promise => { app.use(cors(corsOptions)); app.use(loggingMiddleware); app.use(bodyParser.urlencoded({ extended: true })); + app.use(brazeMessagesMiddleware); const stage = process.env.stage === 'CODE' ? 'CODE' : process.env.stage === 'DEV' ? 'DEV' : 'PROD'; @@ -96,6 +99,9 @@ const buildApp = async (): Promise => { const auxiaConfig = await getAuxiaRouterConfig(); + const brazeWebhookApiKey = (await getSsmValue(stage, 'braze-webhook-api-key')) ?? ''; + const brazeCustomEventApiKey = (await getSsmValue(stage, 'braze-custom-event-api-key')) ?? ''; + // Build the routers app.use( buildEpicRouter( @@ -106,6 +112,8 @@ const buildApp = async (): Promise => { choiceCardAmounts, tickerData, banditData, + brazeWebhookApiKey, + brazeCustomEventApiKey, ), ); app.use( diff --git a/src/server/utils/ssm.ts b/src/server/utils/ssm.ts index 6b485cf3d..c8b0698bc 100644 --- a/src/server/utils/ssm.ts +++ b/src/server/utils/ssm.ts @@ -7,6 +7,7 @@ export async function getSsmValue(stage: string, id: string): Promise