Skip to content
Draft

Braze #1353

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
121 changes: 121 additions & 0 deletions cdk/lib/__snapshots__/dotcom-components.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion cdk/lib/dotcom-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
GuAllowPolicy,
GuDynamoDBReadPolicy,
GuDynamoDBWritePolicy,
GuGetS3ObjectsPolicy,
GuPutCloudwatchMetricsPolicy,
} from '@guardian/cdk/lib/constructs/iam';
Expand Down Expand Up @@ -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: ['*'],
Expand All @@ -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, {
Expand Down
130 changes: 127 additions & 3 deletions src/server/api/epicRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -52,6 +55,8 @@ export const buildEpicRouter = (
choiceCardAmounts: ValueProvider<AmountsTests>,
tickerData: TickerDataProvider,
banditData: ValueProvider<BanditData[]>,
brazeWebhookApiKey: string,
brazeCustomEventApiKey: string,
): Router => {
const router = Router();

Expand All @@ -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 {};
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
};
39 changes: 39 additions & 0 deletions src/server/braze/brazeEpic.ts
Original file line number Diff line number Diff line change
@@ -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<typeof brazeEpicSchema>;

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,
},
});
Loading