From 4bfbe4c75657f3a6d70f2fa8ca64fe93d820f962 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 11:20:21 +0100 Subject: [PATCH 01/12] Braze webhook endpoint --- src/server/api/epicRouter.ts | 40 +++++++++++++++++-- src/server/braze/brazeEpic.ts | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/server/braze/brazeEpic.ts diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index e346478c0..0352d46d2 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -13,9 +13,11 @@ import type { WeeklyArticleLog, } from '../../shared/types'; import { hideSRMessagingForInfoPageIds } from '../../shared/types'; +import type { BrazeEpicTest } from '../braze/brazeEpic'; +import { brazeEpicSchema, transformBrazeEpic } from '../braze/brazeEpic'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; -import { baseUrl } from '../lib/env'; +import { baseUrl, isProd } from '../lib/env'; import type { TickerDataProvider } from '../lib/fetchTickerData'; import { getArticleViewCounts } from '../lib/history'; import type { Params } from '../lib/params'; @@ -26,7 +28,8 @@ 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 { getSsmValue } from '../utils/ssm'; import type { ValueProvider } from '../utils/valueReloader'; interface EpicDataResponse { @@ -44,7 +47,7 @@ interface EpicDataResponse { // Any hardcoded epic tests should go here. They will take priority over any tests from the epic tool. const hardcodedEpicTests: EpicTest[] = []; -export const buildEpicRouter = ( +export const buildEpicRouter = async ( channelSwitches: ValueProvider, superModeArticles: ValueProvider, articleEpicTests: ValueProvider, @@ -55,6 +58,9 @@ export const buildEpicRouter = ( ): Router => { const router = Router(); + const stage = isProd ? 'PROD' : 'CODE'; + const brazeApiKey = await getSsmValue(stage, 'braze-api-key'); + const getArticleEpicTests = ( mvtId: number, isForcingTest: boolean, @@ -226,5 +232,33 @@ export const buildEpicRouter = ( }, ); + router.post('/braze/epic', (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') !== brazeApiKey) { + 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(); + }); + return router; }; diff --git a/src/server/braze/brazeEpic.ts b/src/server/braze/brazeEpic.ts new file mode 100644 index 000000000..d64bc3e0a --- /dev/null +++ b/src/server/braze/brazeEpic.ts @@ -0,0 +1,72 @@ +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 => { + return { + 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, + }, + }; + // const variant: EpicVariant = { + // name: liveblogEpic.variantName, + // heading: liveblogEpic.heading, + // paragraphs: liveblogEpic.paragraphs.split('|'), + // highlightedText: liveblogEpic.highlightedText, + // cta: { + // text: liveblogEpic.ctaText, + // baseUrl: liveblogEpic.ctaBaseUrl, + // }, + // } + // const test: EpicTest = { + // name: liveblogEpic.testName, + // hasArticleCountInCopy: false, + // variants: [variant], + // tagIds: liveblogEpic.tagIds.split('|'), + // status: 'Live', + // channel: 'Epic', + // priority: 0, + // sections: [], + // excludedTagIds: [], + // excludedSections: [], + // alwaysAsk: false, + // userCohort: 'AllExistingSupporters', + // hasCountryName: false, + // highPriority: false, + // useLocalViewLog: false, + // }; + // return { + // variant, + // test, + // }; +}; From 20913d941e65a47c1211170d7b5b11ee66ed4072 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 11:24:26 +0100 Subject: [PATCH 02/12] WithDecryption --- src/server/utils/ssm.ts | 1 + 1 file changed, 1 insertion(+) 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 Date: Wed, 7 May 2025 11:29:35 +0100 Subject: [PATCH 03/12] fix --- src/server/api/epicRouter.ts | 9 +++------ src/server/server.ts | 4 ++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index 0352d46d2..33c0e67c3 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -17,7 +17,7 @@ import type { BrazeEpicTest } from '../braze/brazeEpic'; import { brazeEpicSchema, transformBrazeEpic } from '../braze/brazeEpic'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; -import { baseUrl, isProd } from '../lib/env'; +import { baseUrl } from '../lib/env'; import type { TickerDataProvider } from '../lib/fetchTickerData'; import { getArticleViewCounts } from '../lib/history'; import type { Params } from '../lib/params'; @@ -29,7 +29,6 @@ import type { BanditData } from '../selection/banditData'; import type { Debug } from '../tests/epics/epicSelection'; import { findForcedTestAndVariant, findTestAndVariant } from '../tests/epics/epicSelection'; import { logInfo, logWarn } from '../utils/logging'; -import { getSsmValue } from '../utils/ssm'; import type { ValueProvider } from '../utils/valueReloader'; interface EpicDataResponse { @@ -47,7 +46,7 @@ interface EpicDataResponse { // Any hardcoded epic tests should go here. They will take priority over any tests from the epic tool. const hardcodedEpicTests: EpicTest[] = []; -export const buildEpicRouter = async ( +export const buildEpicRouter = ( channelSwitches: ValueProvider, superModeArticles: ValueProvider, articleEpicTests: ValueProvider, @@ -55,12 +54,10 @@ export const buildEpicRouter = async ( choiceCardAmounts: ValueProvider, tickerData: TickerDataProvider, banditData: ValueProvider, + brazeApiKey: string, ): Router => { const router = Router(); - const stage = isProd ? 'PROD' : 'CODE'; - const brazeApiKey = await getSsmValue(stage, 'braze-api-key'); - const getArticleEpicTests = ( mvtId: number, isForcingTest: boolean, diff --git a/src/server/server.ts b/src/server/server.ts index 0bb5cc95b..e83086288 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -27,6 +27,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(); @@ -96,6 +97,8 @@ const buildApp = async (): Promise => { const auxiaConfig = await getAuxiaRouterConfig(); + const brazeApiKey = (await getSsmValue(stage, 'braze-api-key')) ?? ''; + // Build the routers app.use( buildEpicRouter( @@ -106,6 +109,7 @@ const buildApp = async (): Promise => { choiceCardAmounts, tickerData, banditData, + brazeApiKey, ), ); app.use( From 6c20b9b9e077c3ec28bb8c835f568cc0da21c818 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 11:41:04 +0100 Subject: [PATCH 04/12] cdk dynamodb --- .../dotcom-components.test.ts.snap | 121 ++++++++++++++++++ cdk/lib/dotcom-components.ts | 8 +- 2 files changed, 128 insertions(+), 1 deletion(-) 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..07ba9bbae 100644 --- a/cdk/lib/dotcom-components.ts +++ b/cdk/lib/dotcom-components.ts @@ -10,7 +10,7 @@ import { } from '@guardian/cdk/lib/constructs/core'; import { GuAllowPolicy, - GuDynamoDBReadPolicy, + GuDynamoDBReadPolicy, GuDynamoDBWritePolicy, GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from '@guardian/cdk/lib/constructs/iam'; @@ -241,6 +241,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: ['*'], From 3b3cf9eadc9ee3521f89694e66789a43748c3542 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 11:48:38 +0100 Subject: [PATCH 05/12] dynamo table --- cdk/lib/dotcom-components.ts | 5 ++- src/server/api/epicRouter.ts | 5 ++- src/server/braze/brazeTable.ts | 44 +++++++++++++++++++ .../middleware/brazeMessagesMiddleware.ts | 18 ++++++++ src/server/server.ts | 2 + 5 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 src/server/braze/brazeTable.ts create mode 100644 src/server/middleware/brazeMessagesMiddleware.ts diff --git a/cdk/lib/dotcom-components.ts b/cdk/lib/dotcom-components.ts index 07ba9bbae..77557bc2d 100644 --- a/cdk/lib/dotcom-components.ts +++ b/cdk/lib/dotcom-components.ts @@ -10,7 +10,8 @@ import { } from '@guardian/cdk/lib/constructs/core'; import { GuAllowPolicy, - GuDynamoDBReadPolicy, GuDynamoDBWritePolicy, + GuDynamoDBReadPolicy, + GuDynamoDBWritePolicy, GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from '@guardian/cdk/lib/constructs/iam'; @@ -268,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 33c0e67c3..b31cbfa22 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -30,6 +30,7 @@ import type { Debug } from '../tests/epics/epicSelection'; import { findForcedTestAndVariant, findTestAndVariant } from '../tests/epics/epicSelection'; import { logInfo, logWarn } from '../utils/logging'; import type { ValueProvider } from '../utils/valueReloader'; +import { addBrazeEpicTest } from '../braze/brazeTable'; interface EpicDataResponse { data?: { @@ -229,7 +230,7 @@ export const buildEpicRouter = ( }, ); - router.post('/braze/epic', (req: express.Request, res: express.Response) => { + 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'); @@ -250,7 +251,7 @@ export const buildEpicRouter = ( const liveblogEpic = parseResult.data; const message: BrazeEpicTest = transformBrazeEpic(liveblogEpic); - // await addBrazeEpicTest(liveblogEpic.brazeUUID, message); + await addBrazeEpicTest(liveblogEpic.brazeUUID, message); logInfo(JSON.stringify(message)); res.status(201); diff --git a/src/server/braze/brazeTable.ts b/src/server/braze/brazeTable.ts new file mode 100644 index 000000000..739681084 --- /dev/null +++ b/src/server/braze/brazeTable.ts @@ -0,0 +1,44 @@ +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); +}; 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 e83086288..0b55b1dee 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -28,6 +28,7 @@ import { buildGutterLiveblogTestsReloader } from './tests/gutters/gutterTests'; import { buildHeaderTestsReloader } from './tests/headers/headerTests'; import { logError } from './utils/logging'; import { getSsmValue } from './utils/ssm'; +import { brazeMessagesMiddleware } from './middleware/brazeMessagesMiddleware'; const buildApp = async (): Promise => { const app = express(); @@ -58,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'; From 8cbca978ed12ee16834ecd6edbd7048a2e8dbc48 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 11:50:28 +0100 Subject: [PATCH 06/12] lint --- src/server/api/epicRouter.ts | 2 +- src/server/braze/brazeTable.ts | 4 ++-- src/server/server.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index b31cbfa22..103006b1d 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -15,6 +15,7 @@ import type { import { hideSRMessagingForInfoPageIds } from '../../shared/types'; import type { BrazeEpicTest } from '../braze/brazeEpic'; import { brazeEpicSchema, transformBrazeEpic } from '../braze/brazeEpic'; +import { addBrazeEpicTest } from '../braze/brazeTable'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; import { baseUrl } from '../lib/env'; @@ -30,7 +31,6 @@ import type { Debug } from '../tests/epics/epicSelection'; import { findForcedTestAndVariant, findTestAndVariant } from '../tests/epics/epicSelection'; import { logInfo, logWarn } from '../utils/logging'; import type { ValueProvider } from '../utils/valueReloader'; -import { addBrazeEpicTest } from '../braze/brazeTable'; interface EpicDataResponse { data?: { diff --git a/src/server/braze/brazeTable.ts b/src/server/braze/brazeTable.ts index 739681084..d342260ac 100644 --- a/src/server/braze/brazeTable.ts +++ b/src/server/braze/brazeTable.ts @@ -19,8 +19,8 @@ export const fetchBrazeEpicTests = async (brazeUUID: string): Promise result.Items ?? []) - .catch(error => { + .then((result) => result.Items ?? []) + .catch((error) => { logError(`Error fetching braze epic tests from dynamo: ${error}`); return []; }); diff --git a/src/server/server.ts b/src/server/server.ts index 0b55b1dee..1d40e034e 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'; @@ -28,7 +29,6 @@ import { buildGutterLiveblogTestsReloader } from './tests/gutters/gutterTests'; import { buildHeaderTestsReloader } from './tests/headers/headerTests'; import { logError } from './utils/logging'; import { getSsmValue } from './utils/ssm'; -import { brazeMessagesMiddleware } from './middleware/brazeMessagesMiddleware'; const buildApp = async (): Promise => { const app = express(); From 57a82ba9eacc4ce970cad64d7115919836bc18ae Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 12:07:40 +0100 Subject: [PATCH 07/12] /epic-view, use braze table --- src/server/api/epicRouter.ts | 74 ++++++++++++++++++++++++++++++++-- src/server/braze/brazeTable.ts | 15 +++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index 103006b1d..e99c0856f 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -15,7 +15,7 @@ import type { import { hideSRMessagingForInfoPageIds } from '../../shared/types'; import type { BrazeEpicTest } from '../braze/brazeEpic'; import { brazeEpicSchema, transformBrazeEpic } from '../braze/brazeEpic'; -import { addBrazeEpicTest } from '../braze/brazeTable'; +import { addBrazeEpicTest, removeBrazeEpicTest } from '../braze/brazeTable'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; import { baseUrl } from '../lib/env'; @@ -79,18 +79,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, + }; + + 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 {}; } @@ -183,7 +238,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; @@ -213,7 +268,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; @@ -258,5 +313,18 @@ export const buildEpicRouter = ( res.send(); }); + router.post('/braze/epic-view', async (req: express.Request, res: express.Response) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- TODO + const { brazeMessageIdentifier, brazeUUID } = req.body; + if (brazeUUID && brazeMessageIdentifier) { + await removeBrazeEpicTest(brazeUUID as string, brazeMessageIdentifier as string); + // TODO - send event to braze + res.status(200); + } else { + res.status(400); + } + res.send(); + }); + return router; }; diff --git a/src/server/braze/brazeTable.ts b/src/server/braze/brazeTable.ts index d342260ac..643061839 100644 --- a/src/server/braze/brazeTable.ts +++ b/src/server/braze/brazeTable.ts @@ -42,3 +42,18 @@ export const addBrazeEpicTest = async (brazeUUID: string, test: BrazeEpicTest): .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); +}; From efe09e9478662f27823f76325117d4e3571009a8 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 12:26:30 +0100 Subject: [PATCH 08/12] custom event --- src/server/api/epicRouter.ts | 28 ++++++++++++++++++++++++---- src/server/server.ts | 6 ++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index e99c0856f..96d5726ef 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -55,7 +55,8 @@ export const buildEpicRouter = ( choiceCardAmounts: ValueProvider, tickerData: TickerDataProvider, banditData: ValueProvider, - brazeApiKey: string, + brazeWebhookApiKey: string, + brazeCustomEventApiKey: string, ): Router => { const router = Router(); @@ -117,7 +118,7 @@ export const buildEpicRouter = ( componentType: 'ACQUISITIONS_EPIC', products: ['CONTRIBUTION', 'MEMBERSHIP_SUPPORTER'], // @ts-expect-error -- TODO - brazeMessageIdentifier: brazeTest.testName, + brazeMessageIdentifier: `${brazeTest.testName}:${brazeTest.variantName}`, }; const props: EpicProps = { @@ -289,7 +290,7 @@ export const buildEpicRouter = ( // No need for CORS here, this endpoint is requested server-to-server res.removeHeader('Access-Control-Allow-Origin'); - if (req.header('X-Api-Key') !== brazeApiKey) { + if (req.header('X-Api-Key') !== brazeWebhookApiKey) { res.status(401); res.send(); return; @@ -318,7 +319,26 @@ export const buildEpicRouter = ( const { brazeMessageIdentifier, brazeUUID } = req.body; if (brazeUUID && brazeMessageIdentifier) { await removeBrazeEpicTest(brazeUUID as string, brazeMessageIdentifier as string); - // TODO - send event to braze + + 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_impression', + properties: { + id: brazeMessageIdentifier as string, + }, + time: new Date().toISOString(), + }, + ], + }), + }); res.status(200); } else { res.status(400); diff --git a/src/server/server.ts b/src/server/server.ts index 1d40e034e..3cdfc839e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -99,7 +99,8 @@ const buildApp = async (): Promise => { const auxiaConfig = await getAuxiaRouterConfig(); - const brazeApiKey = (await getSsmValue(stage, 'braze-api-key')) ?? ''; + const brazeWebhookApiKey = (await getSsmValue(stage, 'braze-webhook-api-key')) ?? ''; + const brazeCustomEventApiKey = (await getSsmValue(stage, 'braze-custom-event-api-key')) ?? ''; // Build the routers app.use( @@ -111,7 +112,8 @@ const buildApp = async (): Promise => { choiceCardAmounts, tickerData, banditData, - brazeApiKey, + brazeWebhookApiKey, + brazeCustomEventApiKey, ), ); app.use( From 6bf1d62f183458fb3618af17178a9880d8659f19 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 13:27:07 +0100 Subject: [PATCH 09/12] fix delete --- src/server/api/epicRouter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index 96d5726ef..d79409c1c 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -318,7 +318,8 @@ export const buildEpicRouter = ( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- TODO const { brazeMessageIdentifier, brazeUUID } = req.body; if (brazeUUID && brazeMessageIdentifier) { - await removeBrazeEpicTest(brazeUUID as string, brazeMessageIdentifier as string); + const testName = (brazeMessageIdentifier as string).split(':')[0]; + await removeBrazeEpicTest(brazeUUID as string, testName); await fetch('https://rest.fra-01.braze.eu/users/track', { method: 'POST', From 9a9738042f52d3a6e8127ab09a49e5476e53edea Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 14:05:45 +0100 Subject: [PATCH 10/12] events endpoint --- src/server/api/epicRouter.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index d79409c1c..8a619015b 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -314,10 +314,10 @@ export const buildEpicRouter = ( res.send(); }); - router.post('/braze/epic-view', async (req: express.Request, res: express.Response) => { + 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 } = req.body; - if (brazeUUID && brazeMessageIdentifier) { + const { brazeMessageIdentifier, brazeUUID, eventType } = req.body; + if (brazeUUID && brazeMessageIdentifier && eventType) { const testName = (brazeMessageIdentifier as string).split(':')[0]; await removeBrazeEpicTest(brazeUUID as string, testName); @@ -331,7 +331,7 @@ export const buildEpicRouter = ( events: [ { external_id: brazeUUID as string, - name: 'epic_impression', + name: `epic_${eventType}`, properties: { id: brazeMessageIdentifier as string, }, From 47169cb1ef7684c4c9b5f179c75d88db3902af17 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Wed, 7 May 2025 14:56:56 +0100 Subject: [PATCH 11/12] log --- src/server/api/epicRouter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index 8a619015b..6ac225420 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -321,6 +321,9 @@ export const buildEpicRouter = ( 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: { From efdb5d37d4d8764f89010742752070df0924719d Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Thu, 8 May 2025 09:11:33 +0100 Subject: [PATCH 12/12] tidy --- src/server/braze/brazeEpic.ts | 57 ++++++++--------------------------- 1 file changed, 12 insertions(+), 45 deletions(-) diff --git a/src/server/braze/brazeEpic.ts b/src/server/braze/brazeEpic.ts index d64bc3e0a..640e8cd3d 100644 --- a/src/server/braze/brazeEpic.ts +++ b/src/server/braze/brazeEpic.ts @@ -25,48 +25,15 @@ export const brazeEpicSchema = z.object({ export type BrazeEpic = z.infer; -export const transformBrazeEpic = (liveblogEpic: BrazeEpic): BrazeEpicTest => { - return { - 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, - }, - }; - // const variant: EpicVariant = { - // name: liveblogEpic.variantName, - // heading: liveblogEpic.heading, - // paragraphs: liveblogEpic.paragraphs.split('|'), - // highlightedText: liveblogEpic.highlightedText, - // cta: { - // text: liveblogEpic.ctaText, - // baseUrl: liveblogEpic.ctaBaseUrl, - // }, - // } - // const test: EpicTest = { - // name: liveblogEpic.testName, - // hasArticleCountInCopy: false, - // variants: [variant], - // tagIds: liveblogEpic.tagIds.split('|'), - // status: 'Live', - // channel: 'Epic', - // priority: 0, - // sections: [], - // excludedTagIds: [], - // excludedSections: [], - // alwaysAsk: false, - // userCohort: 'AllExistingSupporters', - // hasCountryName: false, - // highPriority: false, - // useLocalViewLog: false, - // }; - // return { - // variant, - // test, - // }; -}; +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, + }, +});