Skip to content

Commit e5f43c8

Browse files
committed
feat: notify users about expiring sharing
1 parent 5db6135 commit e5f43c8

File tree

8 files changed

+205
-16
lines changed

8 files changed

+205
-16
lines changed

cdk/BackendStack.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { CreateDeviceAPI } from './resources/DeviceManagementAPI.js'
2121
import { DevicesAPI } from './resources/DevicesAPI.js'
2222
import { EmailConfirmationTokens } from './resources/EmailConfirmationTokens.js'
2323
import { JWKS } from './resources/JWKS.js'
24+
import { Notifications } from './resources/Notifications.js'
2425
import { PublicDevices } from './resources/PublicDevices.js'
2526
import { ShareAPI } from './resources/ShareAPI.js'
2627
import { UserAuthAPI } from './resources/UserAuthAPI.js'
@@ -183,6 +184,13 @@ export class BackendStack extends Stack {
183184
api.addRoute('POST /auth', userAuthAPI.requestTokenFn)
184185
api.addRoute('POST /auth/jwt', userAuthAPI.createJWTFn)
185186

187+
new Notifications(this, {
188+
baseLayer,
189+
lambdaSources,
190+
publicDevices,
191+
domain,
192+
})
193+
186194
// JWKS
187195

188196
const jwks = new JWKS(this, {

cdk/packBackendLambdas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type BackendLambdas = {
1616
deviceJwt: PackedLambda
1717
requestToken: PackedLambda
1818
userJwt: PackedLambda
19+
notifyAboutExpiringDevices: PackedLambda
1920
}
2021

2122
const pack = async (id: string) => packLambdaFromPath(id, `lambda/${id}.ts`)
@@ -38,4 +39,5 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
3839
deviceJwt: await pack('deviceJwt'),
3940
requestToken: await pack('requestToken'),
4041
userJwt: await pack('userJwt'),
42+
notifyAboutExpiringDevices: await pack('notifyAboutExpiringDevices'),
4143
})

cdk/resources/Notifications.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { PackedLambdaFn } from '@bifravst/aws-cdk-lambda-helpers/cdk'
2+
import {
3+
type aws_lambda as Lambda,
4+
Duration,
5+
aws_events_targets as EventTargets,
6+
aws_events as Events,
7+
} from 'aws-cdk-lib'
8+
import { Construct } from 'constructs'
9+
import type { BackendLambdas } from '../packBackendLambdas.js'
10+
import type { PublicDevices } from './PublicDevices.js'
11+
import { permissions } from './ses.js'
12+
13+
export class Notifications extends Construct {
14+
constructor(
15+
parent: Construct,
16+
{
17+
domain,
18+
baseLayer,
19+
lambdaSources,
20+
publicDevices,
21+
}: {
22+
domain: string
23+
baseLayer: Lambda.ILayerVersion
24+
lambdaSources: Pick<BackendLambdas, 'notifyAboutExpiringDevices'>
25+
publicDevices: PublicDevices
26+
},
27+
) {
28+
super(parent, 'notifications')
29+
30+
const notifyAboutExpiringDevicesFn = new PackedLambdaFn(
31+
this,
32+
'notifyAboutExpiringDevicesFn',
33+
lambdaSources.notifyAboutExpiringDevices,
34+
{
35+
description: 'Notify users that their devices expire soon',
36+
layers: [baseLayer],
37+
environment: {
38+
FROM_EMAIL: `notification@${domain}`,
39+
IS_TEST: this.node.getContext('isTest') === true ? '1' : '0',
40+
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
41+
},
42+
initialPolicy: [permissions(this, domain)],
43+
},
44+
).fn
45+
publicDevices.publicDevicesTable.grantReadData(notifyAboutExpiringDevicesFn)
46+
47+
const rule = new Events.Rule(this, 'rule', {
48+
description: `Rule to schedule email notifications`,
49+
schedule: Events.Schedule.rate(Duration.days(1)),
50+
})
51+
rule.addTarget(
52+
new EventTargets.LambdaFunction(notifyAboutExpiringDevicesFn),
53+
)
54+
}
55+
}

cdk/resources/UserAuthAPI.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { PackedLambdaFn } from '@bifravst/aws-cdk-lambda-helpers/cdk'
2-
import { aws_iam as IAM, type aws_lambda as Lambda, Stack } from 'aws-cdk-lib'
2+
import { type aws_lambda as Lambda } from 'aws-cdk-lib'
33
import { Construct } from 'constructs'
44
import type { BackendLambdas } from '../packBackendLambdas.js'
55
import type { EmailConfirmationTokens } from './EmailConfirmationTokens.js'
6+
import { permissions } from './ses.js'
67

78
export class UserAuthAPI extends Construct {
89
public readonly requestTokenFn: Lambda.IFunction
@@ -39,21 +40,7 @@ export class UserAuthAPI extends Construct {
3940
FROM_EMAIL: `notification@${domain}`,
4041
IS_TEST: this.node.getContext('isTest') === true ? '1' : '0',
4142
},
42-
initialPolicy: [
43-
new IAM.PolicyStatement({
44-
actions: ['ses:SendEmail'],
45-
resources: [
46-
`arn:aws:ses:${Stack.of(parent).region}:${
47-
Stack.of(parent).account
48-
}:identity/${domain}`,
49-
],
50-
conditions: {
51-
StringLike: {
52-
'ses:FromAddress': `notification@${domain}`,
53-
},
54-
},
55-
}),
56-
],
43+
initialPolicy: [permissions(this, domain)],
5744
},
5845
).fn
5946
emailConfirmationTokens.table.grantWriteData(this.requestTokenFn)

cdk/resources/ses.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { aws_iam as IAM, Stack } from 'aws-cdk-lib'
2+
import type { Construct } from 'constructs'
3+
4+
export const permissions = (
5+
stack: Construct,
6+
domain: string,
7+
): IAM.PolicyStatement =>
8+
new IAM.PolicyStatement({
9+
actions: ['ses:SendEmail'],
10+
resources: [
11+
`arn:aws:ses:${Stack.of(stack).region}:${
12+
Stack.of(stack).account
13+
}:identity/${domain}`,
14+
],
15+
conditions: {
16+
StringLike: {
17+
'ses:FromAddress': `notification@${domain}`,
18+
},
19+
},
20+
})

devices/listExpiringDevices.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ScanCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb'
2+
import { unmarshall } from '@aws-sdk/util-dynamodb'
3+
import { hasItems } from './hasItems.js'
4+
import type { PublicDeviceRecord } from './PublicDeviceRecord.js'
5+
6+
export const listExpiringDevices =
7+
({ db, TableName }: { db: DynamoDBClient; TableName: string }) =>
8+
async (expiresUntil: Date): Promise<Array<PublicDeviceRecord>> => {
9+
const res = await db.send(
10+
new ScanCommand({
11+
TableName,
12+
FilterExpression: '#ttl < :expiresUntil',
13+
ExpressionAttributeNames: {
14+
'#ttl': 'ttl',
15+
},
16+
ExpressionAttributeValues: {
17+
':expiresUntil': {
18+
N: (expiresUntil.getTime() / 1000).toString(),
19+
},
20+
},
21+
}),
22+
)
23+
if (!hasItems(res)) return []
24+
return res.Items.map((i) => unmarshall(i) as PublicDeviceRecord)
25+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { SESClient } from '@aws-sdk/client-ses'
2+
import { SendEmailCommand } from '@aws-sdk/client-ses'
3+
4+
export const sendExpiryNotificationEmail =
5+
(ses: SESClient, fromEmail: string) =>
6+
async ({
7+
email,
8+
ids,
9+
}: {
10+
email: string
11+
ids: Array<string>
12+
}): Promise<void> => {
13+
await ses.send(
14+
new SendEmailCommand({
15+
Destination: {
16+
ToAddresses: [email],
17+
},
18+
Message: {
19+
Body: {
20+
Text: {
21+
Data: [
22+
`The following devices will expire soon and no longer be shared on the map:`,
23+
'',
24+
...ids.map((id) => `- ${id}`),
25+
'',
26+
'Please visit the dashboard to extend the expiration date of your devices.',
27+
].join('\n'),
28+
},
29+
},
30+
Subject: {
31+
Data: `[hello.nrfcloud.com] › Action needed: ${ids.length} of your devices will expire soon`,
32+
},
33+
},
34+
Source: fromEmail,
35+
}),
36+
)
37+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
2+
import { SESClient } from '@aws-sdk/client-ses'
3+
import { fromEnv } from '@bifravst/from-env'
4+
import { requestLogger } from '@hello.nrfcloud.com/lambda-helpers/requestLogger'
5+
import middy from '@middy/core'
6+
import { STACK_NAME } from '../cdk/stackConfig.js'
7+
import { listExpiringDevices } from '../devices/listExpiringDevices.js'
8+
import { sendExpiryNotificationEmail } from '../email/sendExpiryNotificationEmail.js'
9+
10+
const { publicDevicesTableName, fromEmail, isTestString } = fromEnv({
11+
publicDevicesTableName: 'PUBLIC_DEVICES_TABLE_NAME',
12+
version: 'VERSION',
13+
fromEmail: 'FROM_EMAIL',
14+
isTestString: 'IS_TEST',
15+
})({
16+
STACK_NAME,
17+
...process.env,
18+
})
19+
const list = listExpiringDevices({
20+
db: new DynamoDBClient({}),
21+
TableName: publicDevicesTableName,
22+
})
23+
const ses = new SESClient({})
24+
const isTest = isTestString === '1'
25+
const sendEmail = sendExpiryNotificationEmail(ses, fromEmail)
26+
27+
/**
28+
* Notify users that their devices expire soon
29+
*/
30+
const h = async (): Promise<void> => {
31+
const devices = await list(new Date(Date.now() + 1000 * 60 * 60 * 24 * 3))
32+
33+
const devicesByEmail = devices.reduce(
34+
(acc, d) => {
35+
if (acc[d.ownerEmail] === undefined) {
36+
acc[d.ownerEmail] = []
37+
}
38+
acc[d.ownerEmail]!.push(d.id)
39+
return acc
40+
},
41+
{} as Record<string, Array<string>>,
42+
)
43+
44+
for (const [email, ids] of Object.entries(devicesByEmail)) {
45+
console.debug(email, ids.join(', '))
46+
if (!isTest) {
47+
await sendEmail({
48+
email,
49+
ids,
50+
})
51+
}
52+
}
53+
}
54+
55+
export const handler = middy().use(requestLogger()).handler(h)

0 commit comments

Comments
 (0)