Skip to content

Commit 281c1e1

Browse files
committed
feat(export): send email confirmation
1 parent 4490ca4 commit 281c1e1

File tree

8 files changed

+175
-8
lines changed

8 files changed

+175
-8
lines changed

infrastructure/transactional-emails/src/config/index.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,24 @@ export const config = {
3434
listExportReadyCampaignId: isDev
3535
? '45b2e986-d80b-f068-ce9f-5781296e91a4'
3636
: '8a5f66b9-6e28-48e9-af99-e44e2a16a81a',
37+
exportRequestAckCampaignId: 'cab551ad-730e-4a42-8052-a7ae77856a9b',
3738
},
3839
},
3940
eventBridge: {
4041
prefix: 'PocketEventBridge',
4142
topics: [
42-
'UserEvents',
43-
'PremiumPurchaseEvents',
44-
'UserRegistrationEvents',
45-
'ForgotPasswordEvents',
46-
'ListExportReadyEvents',
43+
{ name: 'UserEvents' },
44+
{ name: 'PremiumPurchaseEvents' },
45+
{ name: 'UserRegistrationEvents' },
46+
{ name: 'ForgotPasswordEvents' },
47+
{ name: 'ListExportReadyEvents' },
48+
{
49+
name: 'ListEvents',
50+
filterPolicyScope: 'MessageBody',
51+
filterPolicy: JSON.stringify({
52+
'detail-type': ['list-export-requested'],
53+
}),
54+
},
4755
],
4856
},
4957
};

infrastructure/transactional-emails/src/main.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class TransactionalEmails extends TerraformStack {
5858
});
5959

6060
const topicArns = config.eventBridge.topics.map((topic) => {
61-
const topicArn = `arn:aws:sns:${region.name}:${caller.accountId}:${config.eventBridge.prefix}-${config.environment}-${topic}`;
61+
const topicArn = `arn:aws:sns:${region.name}:${caller.accountId}:${config.eventBridge.prefix}-${config.environment}-${topic.name}`;
6262
this.subscribeSqsToSnsTopic(sqsLambda, snsTopicDlq, topicArn, topic);
6363
return topicArn;
6464
});
@@ -92,19 +92,21 @@ class TransactionalEmails extends TerraformStack {
9292
sqsLambda: TransactionalEmailSQSLambda,
9393
snsTopicDlq: sqsQueue.SqsQueue,
9494
snsTopicArn: string,
95-
topicName: string,
95+
topic: { name: string; filterPolicy?: string; filterPolicyScope?: string },
9696
) {
9797
// This Topic already exists and is managed elsewhere
9898
return new snsTopicSubscription.SnsTopicSubscription(
9999
this,
100-
`${topicName}-sns-subscription`,
100+
`${topic.name}-sns-subscription`,
101101
{
102102
topicArn: snsTopicArn,
103103
protocol: 'sqs',
104104
endpoint: sqsLambda.construct.applicationSqsQueue.sqsQueue.arn,
105105
redrivePolicy: JSON.stringify({
106106
deadLetterTargetArn: snsTopicDlq.arn,
107107
}),
108+
filterPolicy: topic.filterPolicy,
109+
filterPolicyScope: topic.filterPolicyScope,
108110
},
109111
);
110112
}

infrastructure/transactional-emails/src/transactionalEmailSQSLambda.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export class TransactionalEmailSQSLambda extends Construct {
4646
config.lambda.braze.forgotPasswordCampaignId,
4747
BRAZE_LIST_EXPORT_READY_CAMPAIGN_ID:
4848
config.lambda.braze.listExportReadyCampaignId,
49+
BRAZE_EXPORT_REQUEST_ACK_CAMPAIGN_ID:
50+
config.lambda.braze.exportRequestAckCampaignId,
4951
},
5052
ignoreEnvironmentVars: ['GIT_SHA'],
5153
vpcConfig: {

lambdas/transactional-emails/src/braze.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ export async function sendListExportReadyEmail(options: {
119119
return res;
120120
}
121121

122+
export async function sendExportRequestAcknowledged(options: {
123+
encodedId: string;
124+
requestId: string;
125+
}) {
126+
const campaignData: CampaignsTriggerSendObject = {
127+
campaign_id: config.braze.exportRequestAckCampaignId,
128+
recipients: [
129+
{
130+
external_user_id: options.encodedId,
131+
trigger_properties: {
132+
request_id: options.requestId,
133+
},
134+
},
135+
],
136+
};
137+
Sentry.addBreadcrumb({
138+
data: { campaign: 'ExportRequestAcknowledged', campaignData },
139+
});
140+
141+
const body = JSON.stringify(campaignData);
142+
const res = await brazePost(config.braze.campaignTriggerPath, body);
143+
if (!res.ok) {
144+
throw new Error(
145+
`Error ${res.status}: Failed to send Export Request Acknowledged email`,
146+
);
147+
}
148+
return res;
149+
}
150+
122151
export async function sendAccountDeletionEmail(email: string) {
123152
const campaignData: CampaignsTriggerSendObject = {
124153
campaign_id: config.braze.accountDeletionCampaignId,

lambdas/transactional-emails/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,7 @@ export const config = {
3333
listExportReadyCampaignId:
3434
process.env.BRAZE_LIST_EXPORT_READY_CAMPAIGN_ID ||
3535
'asdasd-asdasd-asdasd-asdasdasd-asdas',
36+
exportRequestAckCampaignId:
37+
process.env.BRAZE_EXPORT_REQUEST_ACK_CAMPAIGN_ID || 'asd-asf-asdf-asdf',
3638
},
3739
};

lambdas/transactional-emails/src/handlers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { userRegistrationEventHandler } from './userRegistrationEventHandler.ts'
44
import { forgotPasswordHandler } from './forgotPassword.ts';
55
import { exportReadyHandler } from './listExportReady.ts';
66
import { PocketEvent, PocketEventType } from '@pocket-tools/event-bridge';
7+
import { exportRequestedHandler } from './listExportRequested.ts';
78

89
// Mapping of detail-type (via event bridge message)
910
// to function that should be invoked to process the message
@@ -15,4 +16,5 @@ export const handlers: {
1516
[PocketEventType.ACCOUNT_REGISTRATION]: userRegistrationEventHandler,
1617
[PocketEventType.FORGOT_PASSWORD]: forgotPasswordHandler,
1718
[PocketEventType.EXPORT_READY]: exportReadyHandler,
19+
[PocketEventType.EXPORT_REQUESTED]: exportRequestedHandler,
1820
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import nock, { cleanAll } from 'nock';
2+
import { SQSEvent, SQSRecord } from 'aws-lambda';
3+
import { config } from '../config.ts';
4+
import * as ssm from '../ssm.ts';
5+
import { sendExportRequestAcknowledged } from '../braze.ts';
6+
import { PocketEventType } from '@pocket-tools/event-bridge';
7+
import { serverLogger } from '@pocket-tools/ts-logger';
8+
import { processor } from '../index.ts';
9+
10+
describe('listExportRequested handler', () => {
11+
let serverLoggerSpy: jest.SpyInstance;
12+
13+
beforeEach(() => {
14+
jest
15+
.spyOn(ssm, 'getBrazeApiKey')
16+
.mockImplementation(() => Promise.resolve('api-key'));
17+
serverLoggerSpy = jest.spyOn(serverLogger, 'error');
18+
});
19+
20+
afterEach(() => {
21+
cleanAll();
22+
jest.restoreAllMocks();
23+
});
24+
25+
it('throw an error if event payload is missing encodedId', async () => {
26+
nock(config.braze.endpoint)
27+
.post(config.braze.campaignTriggerPath)
28+
.reply(400, { errors: ['this is an error'] });
29+
30+
const recordWithoutId = {
31+
body: JSON.stringify({
32+
Message: JSON.stringify({
33+
id: '1234567890',
34+
version: '0',
35+
account: '123456789012',
36+
region: 'us-east-2',
37+
time: new Date(),
38+
'detail-type': PocketEventType.EXPORT_REQUESTED,
39+
source: 'web-repo',
40+
detail: {
41+
requestId: 'abc123',
42+
},
43+
}),
44+
}),
45+
};
46+
await processor({
47+
Records: [recordWithoutId] as SQSRecord[],
48+
} as SQSEvent);
49+
expect(serverLoggerSpy).toHaveBeenCalled();
50+
expect(serverLoggerSpy.mock.calls[0][0]['errorData']['message']).toContain(
51+
"data/detail must have required property 'encodedId'",
52+
);
53+
});
54+
55+
it('throws an error if email send response is not 200 OK', async () => {
56+
const record = {
57+
body: JSON.stringify({
58+
Message: JSON.stringify({
59+
id: '1234567890',
60+
version: '0',
61+
account: '123456789012',
62+
region: 'us-east-2',
63+
time: new Date(),
64+
'detail-type': PocketEventType.EXPORT_REQUESTED,
65+
source: 'web-repo',
66+
detail: {
67+
encodedId: 'abc-123',
68+
requestId: '000-111',
69+
archiveUrl:
70+
'https://pocket.co/share/085ba173-fb35-48b0-a76c-33c1561570b9',
71+
},
72+
}),
73+
}),
74+
};
75+
nock(config.braze.endpoint)
76+
.post(config.braze.campaignTriggerPath)
77+
.reply(400, { errors: ['this is an error'] });
78+
79+
await processor({
80+
Records: [record] as SQSRecord[],
81+
} as SQSEvent);
82+
expect(serverLoggerSpy).toHaveBeenCalledTimes(2);
83+
expect(serverLoggerSpy.mock.calls[1][0]['errorData']['message']).toContain(
84+
'Error 400: Failed to send Export Request Acknowledged email',
85+
);
86+
});
87+
88+
it('should retry 3 times if post fails', async () => {
89+
nock(config.braze.endpoint)
90+
.post(config.braze.campaignTriggerPath)
91+
.times(2)
92+
.reply(500, { errors: ['this is an error'] });
93+
94+
nock(config.braze.endpoint)
95+
.post(config.braze.campaignTriggerPath)
96+
.reply(200, { data: ['this is a data'] });
97+
98+
const res = await sendExportRequestAcknowledged({
99+
requestId: 'abc123',
100+
encodedId: '111',
101+
});
102+
const result = (await res.json()) as any;
103+
expect(result.data).toEqual(['this is a data']);
104+
});
105+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { sendExportRequestAcknowledged } from '../braze.ts';
2+
import { PocketEvent, PocketEventType } from '@pocket-tools/event-bridge';
3+
4+
/**
5+
* Given an list export ready event, make a request to send the export ready
6+
* email.
7+
* @param record SQSRecord containing forwarded event from eventbridge
8+
* @throws Error if response is not ok
9+
*/
10+
export async function exportRequestedHandler(event: PocketEvent) {
11+
if (event?.['detail-type'] === PocketEventType.EXPORT_REQUESTED) {
12+
await sendExportRequestAcknowledged({
13+
encodedId: event.detail.encodedId,
14+
requestId: event.detail.requestId,
15+
});
16+
}
17+
}

0 commit comments

Comments
 (0)