Using the new Amazon EventBridge Scheduler to send reminder push notifications to a mobile app (with example CDK code).

Using the new Amazon EventBridge Scheduler to send reminder push notifications to a mobile app (with example CDK code).

ยท

7 min read

I have a client that maintains a sports club mobile app that allows club members to book tennis and squash courts. For a while, we've wanted to add push notifications to remind members of their court booking a few hours before the time, but we've always thought the implementation would be a little heavy. For example, one idea would be to store a reminder time in a DynamoDB database, and for a lambda function to run say once every minute and send out push notifications reminders for any bookings that it might find for that minute. Of course, that means 1440 lambda invocations every day. Not breaking the bank but not a very clean, elegant or sustainable solution either.

So we were delighted when the Amazon EventBridge scheduler was announced in the lead-up to ReInvent 2022! The scheduler is perfect for this use case using a once-off (rather than recurring) schedule and is very simple to implement.

To remind folks, AWS EventBridge is a serverless event bus that makes it easy to connect up applications using data from your apps, integrated SaaS applications, and other AWS services running as part of your platform.

Recently added is the EventBridge Scheduler, and standalone component which allows you to schedule events to trigger at specific times or intervals, resulting in a new event put into the event bus.

The implementation

In our sports club mobile app, you can access a menu to do several things to a booking, for example, send an email with details of your court booking, or add the event to your calendar. We added a new menu item "Set Reminder" and gave the user the option to receive the notification 1, 6 or 24 hours before their match.

The process to create the reminder is quite simple:

  1. The app would POST to a create reminder Rest API (AWS API Gateway endpoint). Included in the payload would be the message, the reminder time and the push notification device token for the mobile device.

  2. That would invoke a create-reminder lambda which would create a one-time schedule in AWS EventBridge Scheduler. The target would be another lambda function, send-reminder.

  3. At the specified time, the send-reminder lambda invokes from the event and in our case, calls out to Firebase to send out our reminder push notification.

Let's get started! First, we need the infrastructure. Here is the CDK code to set up the Rest API, 2 lambda functions and EventBridge event bus, along with associated policies.

import * as cdk from "aws-cdk-lib";
import { Duration } from "aws-cdk-lib";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import { Rule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";

export class EventBridgeRemindersStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Core infra: EventBridgeReminders Rest API
    const eventBridgeRemindersApi = new apigw.RestApi(this, `${id}-gateway`, {
      restApiName: `${id}-gateway`,
      description: "API for creating push notification reminders",
      deployOptions: {
        stageName: "dev",
      },
    });

    // Core infra: Eventbridge event bus
    const eventBus = new cdk.aws_events.EventBus(this, `${id}-event-bus`, {
      eventBusName: `${id}-event-bus`,
    });

    // need to create a service-linked role and policy for 
    // the scheduler to be able to put events onto our bus
    const schedulerRole = new Role(this, `${id}-scheduler-role`, {
      assumedBy: new ServicePrincipal("scheduler.amazonaws.com"),
    });

    new Policy(this, `${id}-schedule-policy`, {
      policyName: "ScheduleToPutEvents",
      roles: [schedulerRole],
      statements: [
        new PolicyStatement({
          effect: Effect.ALLOW,
          actions: ["events:PutEvents"],
          resources: [eventBus.eventBusArn],
        }),
      ],
    });

    // Create reminder lambda
    const createNotificationReminderLambda = new NodejsFunction(
      this,
      "createNotificationReminder",
      {
        runtime: Runtime.NODEJS_16_X,
        functionName: `${id}-create-notification-reminder`,
        entry: "src/functions/notificationReminder/create.ts",
        handler: "handler",
        memorySize: 512,
        timeout: Duration.seconds(3),
        architecture: Architecture.ARM_64,
        environment: {
          SCHEDULE_ROLE_ARN: schedulerRole.roleArn,
          EVENTBUS_ARN: eventBus.eventBusArn,
        },
        initialPolicy: [
          // Give lambda permission to create the group & schedule and pass IAM role to the scheduler
          new PolicyStatement({
            actions: [
              "scheduler:CreateSchedule",
              "scheduler:CreateScheduleGroup",
              "iam:PassRole",
            ],
            resources: ["*"],
          }),
        ],
      }
    );

    const sendNotificationLambda = new NodejsFunction(
      this,
      "sendNotification",
      {
        functionName: `${id}-send-notification`,
        runtime: Runtime.NODEJS_16_X,
        architecture: Architecture.ARM_64,
        handler: "handler",
        entry: "src/functions/notification/send.ts",
        memorySize: 512,
        timeout: Duration.seconds(3),
        bundling: {
          commandHooks: {
            beforeBundling(): string[] {
              return [];
            },
            // This is an easy way to include files in the bundle
            // of your lambda. A more secure method would be to
            // retrieve and cache this file from S3 in the lambda code
            afterBundling(inputDir: string, outputDir: string): string[] {
              return [
                `cp ${inputDir}/src/functions/notification/firebaseapp-config-XXXXXXX.json ${outputDir}`,
              ];
            },
            beforeInstall() {
              return [];
            },
          },
        },
      }
    );

    // Rule to match schedules for users and attach our email customer lambda.
    new Rule(this, "ReminderNotification", {
      description: "Send a push notification reminding user of a booked court",
      eventPattern: {
        source: ["scheduler.notifications"],
        detailType: ["ReminderNotification"],
      },
      eventBus,
    }).addTarget(new LambdaFunction(sendNotificationLambda));

    const notificationReminderRestResource =
      eventBridgeRemindersApi.root.addResource("notification-reminder");
    notificationReminderRestResource.addMethod(
      "POST",
      new apigw.LambdaIntegration(createNotificationReminderLambda)
    );

    // CDK Outputs
    new cdk.CfnOutput(this, "eventBridgeRemindersApiEndpoint", {
      value: eventBridgeRemindersApi.url,
    });
  }
}

Some notes on this code

  • Take note of the various IAM privileges we need to apply to the various components to make this all work. IAM is always fun!

  • As noted in the comments, we're using the esbuild's commandHooks functionality to bundle our Firebase config file. This is just for convenience, but you may want to avoid having to commit a file of secrets like that into source control, and rather have the lambda fetch that file from a secured "secrets" S3 bucket.

  • The EventBridge scheduler is currently only available in the "major" AWS regions, so while a cdk deploy on this code would work in say eu-west-2, when the create-reminder lambda invokes, you would get an "Endpoint not found" error because there is no EventBridge Scheduler endpoint yet in the London region! Check supported regions here.

  • And of course, you'd likely want some authentication on that Rest API!

Before we have a look at our lambda code, let's have a quick look at the incoming payload we are expecting:

{
      device_token: token,
      datetime: reminderDate.format('YYYY-MM-DDThh:mm:ss'), //eg. '2023-01-23T20:30:00'
      message: `Your club match starts in ${hours} ${
        hours === 1 ? 'hour' : 'hours'
      }`
    }

Now let's have a look at the create-reminder lambda code.

import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import {
  SchedulerClient,
  CreateScheduleCommand,
  FlexibleTimeWindowMode,
  CreateScheduleGroupCommand,
} from "@aws-sdk/client-scheduler";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";

const schedulerClient = new SchedulerClient({ region: "eu-west-1" });

const requestSchema = z.object({
  device_token: z.string(),
  datetime: z.string(),
  message: z.string(),
});
type NotificationReminder = z.infer<typeof requestSchema>;

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  if (!event.body) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: "No body found" }),
    };
  }

  const reminder: NotificationReminder = requestSchema.parse(
    JSON.parse(event.body)
  );

  try {
    // Create a schedule group
    // You could argue this should be part of the infra (cdk)
    // I would agree but a CDK construct for a ScheduleGroup
    // is not yet available
    await schedulerClient.send(
      new CreateScheduleGroupCommand({
        Name: "NotificationRulesScheduleGroup",
      })
    );
  } catch (error) {
    // the above would throw if that ScheduleGroup already exists
}

  try {
    const cmd = new CreateScheduleCommand({
      // A rule can't have the same name as another rule
      // in the same Region and on the same event bus.
      Name: `${uuidv4()}`,
      GroupName: "NotificationRulesScheduleGroup",
      Target: {
        RoleArn: process.env.SCHEDULE_ROLE_ARN,
        Arn: process.env.EVENTBUS_ARN,
        EventBridgeParameters: {
          DetailType: "ReminderNotification",
          Source: "scheduler.notifications", 
        },
        Input: JSON.stringify({ ...reminder }),
      },
      FlexibleTimeWindow: {
        Mode: FlexibleTimeWindowMode.OFF,
      },
      Description: `Send push notification to ${reminder.device_token} at ${reminder.datetime}`,
      ScheduleExpression: `at(${reminder.datetime})`,
    });
    await schedulerClient.send(cmd);
  } catch (error) {
    console.log("failed", error);
  }

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: "Notification reminder created successfully",
    }),
  };
};

Some notes

  • We use the excellent zod library which is a great way to get from a json payload to a Typescript type via explicitly validating against a predefined schema.

  • The lambda creating the Schedule Group is a temporary workaround for this not yet being available in CDK, as Schedule Group makes more sense as an infrastructure concern IMO.

  • We set the FlexibleTimeWindow to OFF as we want the target lambda to invoke immediately. In practice, I notice this is usually within 30 seconds, but can be up to 50 seconds. Setting the window to FLEXIBLE can make sense when you deliberately might want jitter in your invocations, for example restarting a fleet of EC2 instances but not wanting them all to restart at the same time!

Now let's look at the send-notification lambda code:

import { EventBridgeEvent } from "aws-lambda";
import { NotificationReminder } from "../notificationReminder/create";
import * as fbAdmin from "firebase-admin";

export const handler = async (
  event: EventBridgeEvent<"ReminderNotification", NotificationReminder>
) => {
  if (!fbAdmin.apps.length) {
    process.env.GOOGLE_APPLICATION_CREDENTIALS =
      "./firebaseapp-config-XXXXXXX.json";
    fbAdmin.initializeApp({ credential: fbAdmin.credential.applicationDefault() });
  }

  const result = await fbAdmin
    .messaging()
    .sendToDevice(event.detail.device_token, {
      notification: {
        title: `Court Reminder`,
        body: event.detail.message,
        sound: "default",
      },
    });

  console.log("result", JSON.stringify(result));
};

Not much to explain here - we're just using the Firebase Admin SDK to send the push notification itself, using the data from the EventBridge event.

And voila! We receive our notification. It's game time ๐Ÿ˜Ž

Full source code is available on GitHub.

And Finally

Thanks to David Boyne and the ServerlessLand folks for the inspiration. Check out ServerlessLand for other great event-based patterns and specifically this one that inspired this feature & post!

ย