DEV Community

Matia Rašetina
Matia Rašetina

Posted on

I Built a Complete Event Management Platform with $0 Server Costs With NextJS and AWS (Here’s How)

In this blog post, we are going over the project which is called “EventSpark” - a serverless event management app for meetups or conferences. The frontend is done in NextJS and the backend is fully done in AWS.

As mentioned, the frontend was done in NextJS with libraries which are crucial for this project:

  • Zod - schema validation used during event creation
  • NextAuth.JS - authentication library making it easier to store sensitive token information for accessing the backend
  • ShadCN - pre-built component library making it easier to develop more complex UIs on the website

On the other side, we have multiple AWS services which were used to accomplish this project with best practices and makes the project completely serverless. AWS services used are:

  • Lambda
  • API Gateway
  • S3
  • CloudFront
  • CloudFormation
  • DynamoDB
  • Cognito

The motivation for this project is to create a fully serverless project with an example of AWS Cognito implementation with NextAuth.JS library. But you’ll see later, that many interesting methods have been implemented to make the project even better than originally planned!

The usage patterns for all of these services will be explained in the later parts of this blog post.

Architecture overview

The application has been seperated into 2 seperate microservices:

  1. Authentication stack
  2. Events Stack

Let’s go into more detail for each of the stacks and explain the architecture behind them.

Authentication Stack Deep Dive

The authentication stack consists of:

  1. 2 seperate Lambdas
    1. Login Lambda
    2. Register Lambda
  2. API Gateway to open up the Lambdas to the Internet
  3. Cognito User pool

This stack demonstrates the capabilities of AWS Cognito and showcases how straightforward it is to implement user management on any platform. It is important to mention that this stack can be reused in any of the other projects which you may have or can be implemented into any future projects that you may do! Here is the architectural diagram of the Authentication stack.

Architectural diagram of the Authentication stack using AWS Cognito<br>

Our client (NextJS frontend) calls our API gateway URL which forwards the request to either our Login or Register Lambda. Register Lambda takes the email, password and name from the request, registers the user in Cognito and in case of successful registration, the user gets the notification that they have been successfully registered, on the other side, the Login Lambda takes the received credentials, verifies the credentials with Cognito and if the user entered the correct credentials, the client gets the access tokens which are later used for the Event Stack’s Cognito Authorizer (explained later).

It is important to note that the Lambdas are built using Serverless Application Model (SAM) and the build instructions are containted inside the Makefile. We are doing it this way to evade duplication of the requirements.txt files - the library we are adding is AWS Lambda Powertools for better logging experience.

Let’s see each of the services defined inside this template in more detail.

AWS Cognito

To initialize our User pool, the following CloudFormation template is used:

# Cognito User Pool
  EventSparkUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: EventSparkUserPool
      AutoVerifiedAttributes:
        - email
      UsernameAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: false
          RequireUppercase: true
      Schema:
        - Name: email
          AttributeDataType: String
          Mutable: true
          Required: true
        - Name: name
          AttributeDataType: String
          Mutable: true
          Required: false
  # User Pool Client
  EventSparkUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref EventSparkUserPool
      ClientName: EventSparkWebApp
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
        - ALLOW_USER_PASSWORD_AUTH
      PreventUserExistenceErrors: ENABLED
Enter fullscreen mode Exit fullscreen mode

API Gateway

To open up our Lambdas, we use API Gateway resource:

# API Gateway for Auth Services
  EventSparkAuthApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: eventspark
      Cors:
        AllowMethods: "'*'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
Enter fullscreen mode Exit fullscreen mode

AWS Lambda

Our 2 Lambdas are defined in the following YAML code:

RegisterUserLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - cognito-idp:SignUp
                - cognito-idp:AdminConfirmSignUp
              Resource: !GetAtt EventSparkUserPool.Arn
      Environment:
        Variables:
          POWERTOOLS_SERVICE_NAME: authentication
          USER_POOL_ID: !Ref EventSparkUserPool
          USER_POOL_CLIENT_ID: !Ref EventSparkUserPoolClient
      Events:
        RegisterUser:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkAuthApi
            Path: /register
            Method: post
  LoginUserLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - cognito-idp:InitiateAuth
              Resource: !GetAtt EventSparkUserPool.Arn
      Environment:
        Variables:
          POWERTOOLS_SERVICE_NAME: authentication
          USER_POOL_ID: !Ref EventSparkUserPool
          USER_POOL_CLIENT_ID: !Ref EventSparkUserPoolClient
      Events:
        LoginUser:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkAuthApi
            Path: /login
            Method: post
Enter fullscreen mode Exit fullscreen mode

Stack Outputs

You can notice that there are outputs of this stack — these are used for getting more information about the stack after deploying them to AWS:

  • AuthApiEndpoint — URL to our Lambda resources
  • Cognito User pool information — used in our Events Stack (explained later)
Outputs:
  AuthApiEndpoint:
    Description: "Auth API Gateway URL"
    Value: !Sub "<https://$>{EventSparkAuthApi}.execute-api.${AWS::Region}.amazonaws.com/eventspark"
  UserPoolId:
    Description: "User Pool ID for EventSpark"
    Value: !Ref EventSparkUserPool
  UserPoolClientId:
    Description: "User Pool Client ID for EventSpark"
    Value: !Ref EventSparkUserPoolClient
  UserPoolArn:
    Description: "User Pool ARN for EventSpark"
    Value: !GetAtt EventSparkUserPool.Arn
Enter fullscreen mode Exit fullscreen mode

Interaction of the Authentication Lambdas with AWS Cognito

One of the critical points of this project is to understand how our 2 Lambdas work with Cognito. The following code will be in Python and it will interact with AWS Cognito via the Python’s boto3 library for AWS services.

Here is the code snippet from the Register Lambda to sign up the user — rest of the code (processing the request, logging statements etc.) can be seen inside the Github repository linked at the beginning of this blog post.

# Register the user in Cognito
response = cognito_client.sign_up(
    ClientId=USER_POOL_CLIENT_ID,
    Username=email,
    Password=password,
    UserAttributes=[
        {
            'Name': 'email',
            'Value': email
        },
        {
            'Name': 'name',
            'Value': name
        }
    ]
)

# Auto confirm the user (for development purposes)
# In production, you might want to use email verification
cognito_client.admin_confirm_sign_up(
    UserPoolId=USER_POOL_ID,
    Username=email
)
Enter fullscreen mode Exit fullscreen mode

On the other side, we have the Login Lambda, which will with the provided login credentials from our NextJS frontend, communicate with Cognito to verify that the user is existing and has entered the correct password and will receive IdToken, AccessToken, RefreshToken and ExpiresIn fields. All of these fields are very important because the tokens will be used by Cognito Authorizer on Events Stack API Gateway to authenticate and authorize that the user has access to the API endpoints. Getting the mentioned tokens is easy, as you can see in the following code:

# Authenticate user using Cognito
response = cognito_client.initiate_auth(
    ClientId=USER_POOL_CLIENT_ID,
    AuthFlow='USER_PASSWORD_AUTH',
    AuthParameters={
        'USERNAME': email,
        'PASSWORD': password
    }
)

# Extract tokens from the response
id_token = response['AuthenticationResult']['IdToken']
access_token = response['AuthenticationResult']['AccessToken']
refresh_token = response['AuthenticationResult']['RefreshToken']
expires_in = response['AuthenticationResult']['ExpiresIn']
Enter fullscreen mode Exit fullscreen mode

Events Stack Deep Dive

In this part of the blog post, let’s go over the architectural diagram and CloudFormation template first and take the template piece by piece and explain the implementation.

Here is the image of the diagram and YAML CloudFormation template:

Architecture Diagram of the Event Stack

Let’s break down this architecture which was made inside a CloudFormation YAML file.

AWS DynamoDB

We have 2 tables, where:

EventSparkEventTable contains the information about the events which are shown on the platform,

EventSparkEventTable:
  Type: AWS::DynamoDB::Table
  Properties:
    AttributeDefinitions:
    - AttributeName: uuid
      AttributeType: S
    - AttributeName: creator
      AttributeType: S
    KeySchema:
    - AttributeName: uuid
      KeyType: HASH
    GlobalSecondaryIndexes:
      - IndexName: CreatorIndex
        KeySchema:
        - AttributeName: creator
          KeyType: HASH
        Projection:
          ProjectionType: ALL
    BillingMode: PAY_PER_REQUEST
Enter fullscreen mode Exit fullscreen mode

while EventSparkEventUserAttendanceTable contains the information which users are attending which event

EventSparkEventUserAttendanceTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: uuid
          AttributeType: S
        - AttributeName: user_email
          AttributeType: S
        - AttributeName: event_uuid
          AttributeType: S
      KeySchema:
        - AttributeName: uuid
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: UserEmailIndex
          KeySchema:
            - AttributeName: user_email
              KeyType: HASH
          Projection:
            ProjectionType: ALL
        - IndexName: EventUuidIndex
          KeySchema:
            - AttributeName: event_uuid
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      BillingMode: PAY_PER_REQUEST
Enter fullscreen mode Exit fullscreen mode

Both DynamoDB tables have Global Secondary Indexes (GSI) which enables us to use the query method instead of scan method in our Lambdas to gather the information from these tables - this improves data lookup performance, therefore lowering Lambda cost.

S3 + CloudFront

S3 bucket EventSparkBucketcontaining event banners which are images, images are uploaded by the frontend itself by using pre-signed URLs for upload,

EventSparkBucket:
  Type: AWS::S3::Bucket
  Properties:
    PublicAccessBlockConfiguration:
      BlockPublicAcls: false
      BlockPublicPolicy: false
      IgnorePublicAcls: false
      RestrictPublicBuckets: false
    CorsConfiguration:
      CorsRules:
        - AllowedHeaders:
            - "*"
          AllowedMethods:
            - PUT
            - POST
            - DELETE
          AllowedOrigins:
            - "*"
          MaxAge: 3000
Enter fullscreen mode Exit fullscreen mode

and in combination with this S3 bucket we are going to use CloudFront Distribution — using this service enables us to have our event banner images closer to the user globally therefore improving loading times of our media in the browser. You can see my previous blog post about the impact of CloudFront in a full-stack application!

# CloudFront setup
  EventSparkCloudFrontOAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: EventSparkOAC
        Description: OAC for CloudFront to access S3
        SigningProtocol: sigv4
        SigningBehavior: always
        OriginAccessControlOriginType: s3

  EventSparkCloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - Id: S3Origin
            DomainName: !GetAtt EventSparkBucket.RegionalDomainName
            S3OriginConfig:
              OriginAccessIdentity: ""
            OriginAccessControlId: !Ref EventSparkCloudFrontOAC
        Enabled: true
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: none
        ViewerCertificate:
          CloudFrontDefaultCertificate: true
Enter fullscreen mode Exit fullscreen mode

API Gateway

To open up our Lambdas to the Internet, we need to use API Gateway. However, we are going to attach Cognito Authorizer — each of the Lambdas which are protected with a Cognito Authorizer will require an Authorization header inside the request which contains the token received from our Login Lambda from the Authentication stack. If the request provides an expired, invalid or no token, our API Gateway will return a 401 Unauthorized error code. This makes protecting our endpoints so much easier since everything is done by Cognito and API Gateway, without needing any additional code! This way, we protect our APIs from unauthorized access and many attacks on our platform. To connect our API Gateway instance and Cognito User Pool, we needed to output our User Pool information and import it into this stack to make it work.

# API Gateway using standard REST API (not HttpApi)
  EventSparkRestApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: eventspark
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !Ref UserPoolArn
            Identity:
              Header: Authorization
        AddDefaultAuthorizerToCorsPreflight: false
      Cors:
        AllowMethods: "'*'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
Enter fullscreen mode Exit fullscreen mode

AWS Lambda

In this section, we are going to go over all Lambdas inside this stack and explain their logic, together with their CF templates and main logic in the code. One important note, all Lambdas, except one, are protected by our Cognito Authorizer

Create Event Lambda

  • receives and validates the incoming request
  • put the event information inside the EventSparkEventTable DynamoDB table
  • create a pre-signed URL which can be used by the client for an image upload to our S3 bucket and return it to the client

Here is the CloudFormation template for this Lambda:

CreateEventLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:PutItem
              Resource: !GetAtt EventSparkEventTable.Arn
            - Effect: Allow
              Action:
                - s3:PutObject
              Resource:
                - !Sub "${EventSparkBucket.Arn}/*.jpg"
                - !Sub "${EventSparkBucket.Arn}/*.png"
      Environment:
        Variables:
          EventSpark_CLOUDFRONT_URL: !Sub "https://${EventSparkCloudFrontDistribution.DomainName}"
          EVENTS_DYNAMODB_TABLE: !Ref EventSparkEventTable
          EVENTS_S3_BUCKET: !Ref EventSparkBucket
          USER_POOL_ID: !Ref UserPoolId
      Events:
        CreateEvent:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkRestApi
            Path: /create-event
            Method: post
Enter fullscreen mode Exit fullscreen mode

And the code which follows the logic mentioned above:

def create_new_listing(event_id, title, description, date, start_time, end_time, 
                      location_type, location, category, max_attendees, privacy, 
                      creator, s3_key, dynamodb_class, s3_class):
    try:
        # Save to DynamoDB
        dynamodb_class.table.put_item(
            Item={
                "uuid": event_id,
                "title": title,
                "description": description,
                "date": date,
                "startTime": start_time,
                "endTime": end_time,
                "locationType": location_type,
                "location": location,
                "category": category,
                "maxAttendees": str(max_attendees),
                "privacy": privacy,
                "creator": creator,
                "s3_key": s3_key
            }
        )
        logger.info("Event created successfully", extra={
            "event_id": event_id
        })

        # Generate a presigned URL for uploading
        presigned_url = s3_class.client.generate_presigned_url(
            "put_object",
            Params={"Bucket": s3_class.bucket_name, "Key": s3_key},
            ExpiresIn=60  # 1 minute expiration
        )

        logger.info("Presigned URL generated successfully", extra={
            "event_id": event_id
        })

        return {
            "statusCode": 200,
            "headers": {
                'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
                'Access-Control-Allow-Credentials': 'true',
                'Content-Type': 'application/json'
            },
            "body": json.dumps({
                "event_id": event_id,
                "upload_url": presigned_url
            })
        }

    except Exception as e:
        logger.error("Failed to create event listing", extra={
            "error": str(e),
            "event_id": event_id
        })
        return {"statusCode": 500, "body": json.dumps({"error": "Failed to create listing"})}
Enter fullscreen mode Exit fullscreen mode

Get Events Lambda

  • if the event_id is present inside the query params, the provided event_id will be used to fetch data from the EventSparkEventTable together with a CloudFront URL to access the event banner and the data will be returned to the user to render it
  • if the event_id is not provided, the Lambda will scan for first 10 items inside the EventSparkEventTable , genereate CloudFront URLs for the collected events banners and return the information to the user.
GetEventsLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Scan
                - dynamodb:GetItem
              Resource: !GetAtt EventSparkEventTable.Arn
      Environment:
        Variables:
          EVENTSPARK_CLOUDFRONT_URL: !Sub "https://${EventSparkCloudFrontDistribution.DomainName}"
          EVENTS_DYNAMODB_TABLE: !Ref EventSparkEventTable
          USER_POOL_ID: !Ref UserPoolId
      Events:
        GetEvents:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkRestApi
            Path: /get-events
            Method: get
            Auth:
              Authorizer: NONE
Enter fullscreen mode Exit fullscreen mode

Notice how, inside the CF template under “Properties -> Events -> GetEvents -> Properties -> Auth” we marked the Authorizer as NONE — the reason is that since our platform is public, we don’t want all users to login to access the data — the available events are shown regardless if the user is logged in/registered or not. This way, the incoming request to the Lambda doesn’t go to the Cognito Authorizer to get checked.

Here is the code:

def get_events_via_cloudfront(event, dynamodb_resource):
    query_params = event.get('queryStringParameters', {}) or {}
    limit = int(query_params.get('limit', 10))
    event_id = query_params.get('event_id', None)
    exclusive_start_key = query_params.get('start_key')

    logger.info("Processing get_events_via_cloudfront request", extra={
        "event_id": event_id,
        "limit": limit,
        "exclusive_start_key": exclusive_start_key
    })

    items = []
    last_evaluated_key = None
    cloudfront_url = environ.get("EVENTSPARK_CLOUDFRONT_URL", "")

    # If event_id is provided, use get_item for direct lookup
    if event_id:
        # Use get_item for direct primary key lookup
        response = dynamodb_resource.table.get_item(
            Key={
                'uuid': event_id
            }
        )

        # Check if item was found
        if 'Item' in response:
            item = response['Item']
            item['file_url'] = f'{cloudfront_url}/{item["s3_key"]}'
            items = [item]
    else:
        # Initialize the scan parameters
        scan_kwargs = {
            'Limit': limit,
        }

        if exclusive_start_key:
            scan_kwargs['ExclusiveStartKey'] = {'uuid': exclusive_start_key}

        response = dynamodb_resource.table.scan(**scan_kwargs)
        items = response.get('Items', [])
        last_evaluated_key = response.get('LastEvaluatedKey')

        # Construct file URLs correctly
        for item in items:
            item['file_url'] = f'{cloudfront_url}/{item["s3_key"]}'

    # Get the ALLOWED_ORIGIN from environment or default to '*'
    ALLOWED_ORIGIN = environ.get('ALLOWED_ORIGIN', '*')

    logger.info("Returning events", extra={
        "items_count": len(items),
        "last_evaluated_key": last_evaluated_key
    })

    return {
        'statusCode': 200,
        'headers': {
            'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
            'Access-Control-Allow-Credentials': 'true',
            'Access-Control-Allow-Methods': 'GET, OPTIONS',
            'Content-Type': 'application/json'
        },
        'body': json.dumps({
            'events': items,
            'last_evaluated_key': last_evaluated_key
        })
    }
Enter fullscreen mode Exit fullscreen mode

Get Events That User Created Lambda

This Lambda will query the EventSparkEventUserAttendanceTable with user’s email to fetch the information about the events that the user created and return to the client - used on the user’s dashboard.

GetEventsThatUserCreatedLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Query
              Resource:
                - !GetAtt EventSparkEventTable.Arn
                - !Sub "${EventSparkEventTable.Arn}/index/*"

      Environment:
        Variables:
          EVENTS_DYNAMODB_TABLE: !Ref EventSparkEventTable
          EVENTSPARK_CLOUDFRONT_URL: !Sub "https://${EventSparkCloudFrontDistribution.DomainName}"
      Events:
        GetEventsThatUserCreatedEndpoint:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkRestApi
            Path: /get-events-that-user-created
            Method: get
Enter fullscreen mode Exit fullscreen mode
def get_events_that_user_created(event, dynamodb_resource):
    query_params = event.get('queryStringParameters', {}) or {}
    user_email = query_params.get('user_email', None)
    logger.info("Processing get_events_that_user_created request", extra={
        "user_email": user_email
    })

    # Standard response headers
    headers = {
        'Access-Control-Allow-Origin': environ.get('ALLOWED_ORIGIN', '*'),
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
        'Content-Type': 'application/json'
    }

    if not user_email:
        logger.warning("Missing user_email in query parameters")
        return {
            'statusCode': 400,
            'headers': headers,
            'body': json.dumps({'error': 'user_email is required'})
        }

    try:
        # Getting all events user registered for - query by user_email only
        response = dynamodb_resource.table.query(
            IndexName='CreatorIndex',
            KeyConditionExpression='creator = :creator',
            ExpressionAttributeValues={
                ':creator': user_email
            }
        )

        items = response.get('Items', [])

        # Add CloudFront URLs to the items
        cloudfront_url = environ.get("EVENTSPARK_CLOUDFRONT_URL", "")
        for item in items:
            if 's3_key' in item:
                item['file_url'] = f'{cloudfront_url}/{item["s3_key"]}'
        logger.info("Successfully retrieved events created by user", extra={
            "user_email": user_email,
            "event_count": len(items)
        })
        return {
            'statusCode': 200,
            'headers': headers,
            'body': json.dumps({'events': items})
        }
    except Exception as e:
        logger.error("Error retrieving events created by user", extra={
            "user_email": user_email,
            "error": str(e)
        })
        return {
            'statusCode': 500,
            'headers': headers,
            'body': json.dumps({'error': str(e)})
        }
Enter fullscreen mode Exit fullscreen mode

Get Events That User Registered To Lambda

This Lambda will query the EventSparkEventUserAttendanceTable with user’s email to see to which events did the user register to and marked that they will attend the event - used on the user’s dashboard.

GetEventsThatUserRegisteredToLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Query
              Resource:
                - !GetAtt EventSparkEventUserAttendanceTable.Arn
                - !Sub "${EventSparkEventUserAttendanceTable.Arn}/index/*"
            - Effect: Allow
              Action:
                - dynamodb:BatchGetItem
              Resource: !GetAtt EventSparkEventTable.Arn
      Environment:
        Variables:
          EVENTSPARK_CLOUDFRONT_URL: !Sub "https://${EventSparkCloudFrontDistribution.DomainName}"
          EVENTS_USER_REGISTRATION_TABLE: !Ref EventSparkEventUserAttendanceTable
          EVENTS_DYNAMODB_TABLE: !Ref EventSparkEventTable
      Events:
        GetEventsThatUserRegisteredToEndpoint:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkRestApi
            Path: /get-events-that-user-registered-to
            Method: get
Enter fullscreen mode Exit fullscreen mode
def get_events_that_user_registered_to(event, dynamodb_resource):
    query_params = event.get('queryStringParameters', {}) or {}
    user_email = query_params.get('user_email', None)
    event_uuid = query_params.get('event_uuid', None)

    logger.info("Processing get_events_that_user_registered_to request", extra={
        "user_email": user_email,
        "event_uuid": event_uuid
    })

    # Standard response headers
    headers = {
        'Access-Control-Allow-Origin': environ.get('ALLOWED_ORIGIN', '*'),
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
        'Content-Type': 'application/json'
    }

    if not user_email:
        logger.warning("Missing user_email in query parameters")
        return {
            'statusCode': 400,
            'headers': headers,
            'body': json.dumps({'error': 'user_email is required'})
        }

    try:
        # If checking for specific event attendance, query by user_email and filter by event_uuid
        if event_uuid:
            # Query the UserEmailIndex GSI to find registrations for this user
            response = dynamodb_resource.attendance_table.query(
                IndexName='UserEmailIndex',
                KeyConditionExpression='user_email = :user_email',
                FilterExpression='event_uuid = :event_uuid',
                ExpressionAttributeValues={
                    ':user_email': user_email,
                    ':event_uuid': event_uuid
                }
            )

            # Check if any registrations exist for this event
            is_attending = len(response.get('Items', [])) > 0
            logger.info("Checked event attendance", extra={
                "user_email": user_email,
                "event_uuid": event_uuid,
                "is_attending": is_attending
            })
            return {
                'statusCode': 200,
                'headers': headers,
                'body': json.dumps({'isAttending': is_attending})
            }

        # Getting all events user registered for - query by user_email only
        response = dynamodb_resource.attendance_table.query(
            IndexName='UserEmailIndex',
            KeyConditionExpression='user_email = :user_email',
            ExpressionAttributeValues={
                ':user_email': user_email
            }
        )

        items = response.get('Items', [])

        # Get the event UUIDs from the items
        event_uuids = [item['event_uuid'] for item in items if 'event_uuid' in item]
        if not event_uuids:
            logger.info("No events found for user", extra={
                "user_email": user_email
            })
            return {
                'statusCode': 200,
                'headers': headers,
                'body': json.dumps({'events': []})
            }
        # Use BatchGetItem to retrieve multiple events by their UUIDs
        request_items = {
            dynamodb_resource.events_table_name: {
                'Keys': [{'uuid': uuid} for uuid in event_uuids]
            }
        }
        max_retries = 5
        retries = 0
        event_items = []
        while request_items and retries < max_retries:
            response = dynamodb_resource.resource.batch_get_item(RequestItems=request_items)
            event_items.extend(response.get('Responses', {}).get(dynamodb_resource.events_table_name, []))
            request_items = response.get('UnprocessedKeys', {})
            retries += 1

        if request_items:
            logger.warning("UnprocessedKeys still present after retries", extra={
                "unprocessed_keys": request_items
            })

        # If no events found, return an empty list
        if not event_items:
            logger.info("No events found for user", extra={
                "user_email": user_email
            })
            return {
                'statusCode': 200,
                'headers': headers,
                'body': json.dumps({'events': []})
            }

        # Add CloudFront URLs to the items
        cloudfront_url = environ.get("EVENTSPARK_CLOUDFRONT_URL", "")
        for item in event_items:
            if 's3_key' in item:
                item['file_url'] = f'{cloudfront_url}/{item["s3_key"]}'

        logger.info("Successfully retrieved events registered by user", extra={
            "user_email": user_email,
            "event_count": len(event_items)
        })
        return {
            'statusCode': 200,
            'headers': headers,
            'body': json.dumps({'events': event_items})
        }
    except Exception as e:
        logger.error("Error retrieving events registered by user", extra={
            "user_email": user_email,
            "error": str(e)
        })
        return {
            'statusCode': 500,
            'headers': headers,
            'body': json.dumps({'error': str(e)})
        }
Enter fullscreen mode Exit fullscreen mode

Register User To Event Lambda

Finally, this Lambda will add a new entry inside the EventSparkEventUserAttendanceTable with event_id and user’s email to mark them as attending and registered.

RegisterUserToEventLambda:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: makefile
    Properties:
      Runtime: python3.12
      Handler: lambda_handler.lambda_handler
      CodeUri: ./
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:PutItem
              Resource: !GetAtt EventSparkEventUserAttendanceTable.Arn
      Environment:
        Variables:
          EVENTS_USER_REGISTRATION_TABLE: !Ref EventSparkEventUserAttendanceTable
      Events:
        RegisterUserToEvent:
          Type: Api
          Properties:
            RestApiId: !Ref EventSparkRestApi
            Path: /register-user-to-event
            Method: post
Enter fullscreen mode Exit fullscreen mode
def register_user_to_event(
    event_uuid,
    user_email,
    dynamodb_class
):
    try:
        # Save to DynamoDB
        dynamodb_class.table.put_item(
            Item={
                "uuid": str(uuid.uuid4()),
                "event_uuid": event_uuid,
                "user_email": user_email
            }
        )
        logger.info("User registered successfully", extra={
            "event_uuid": event_uuid,
            "user_email": user_email
        })
        return {
            "statusCode": 200,
            "headers": {
                'Access-Control-Allow-Origin': environ.get('ALLOWED_ORIGIN', '*'),
                'Access-Control-Allow-Credentials': 'true',
                'Content-Type': 'application/json'
            },
            "body": json.dumps({
                "message": "User registered successfully"
            })
        }

    except Exception as e:
        logger.error("Error saving to DynamoDB", extra={
            "event_uuid": event_uuid,
            "user_email": user_email,
            "error": str(e)
        })
        return {"statusCode": 500, "body": json.dumps({"error": "Failed to register user to event"})}
Enter fullscreen mode Exit fullscreen mode

Very important to mention is that all Lambdas have the best IAM practices — and that is the least privilege practice — they cannot do any actions on any resources which are not defined inside the CloudFormation template.

You may notice that CORS has been put as ‘*’ — that is made on purpose since this project is only used for learning purposes and this is not a production environment.

How does the application look and what are the flows?

Let’s go over the application and see how it looks after connecting it with our AWS backend!

EventSpark Homepage

After clicking “Explore Events” button present on the home page, you will get all available events on the platform — the following page communicates with our GetEvents Lambda:

Example of all available events

Logging in and signing up is easy when using AWS Cognito! The User pool configured for this project requires the user’s name, email and a password, here is the form which communicates with our Login and Register Lambdas, depending if the user is signing up or signing in:

Sign up form

After signing up, you will be redirected to the dashboard which is not available to the users which are not logged in. The dashboard page communicates with GetEventsThatUserCreated and GetEventsThatUserRegisteredTo Lambdas and those are shown inside the “My Events” and “Registered Events” tabs respectively. Here is how the dashboard looks:

Dashboard for a logged in user

Finally, if the user wants to create an event, this is how the flow would look like — after filling out the form, the information about the event is sent to our CreateEvent Lambda:

Create Event Flow

Finally, if a user wants to attend and register to an event, here is the flow the user needs to do — of course, in this flow, we communicate with our last Lambda RegisterUserToEvent Lambda.

User registration to Event

And that would be it for this blog post where we’ve gone over a fully serverless application architecture, used best practices and created a fully working full-stack application! The lessons you can take from this project are:

  • how to implement AWS Cognito for easy user management and implementation of the Cognito Authorizer inside other stack’s API Gateway
  • how to implement Global Secondary Indexes (GSI) to make your DynamoDB data fetching process faster
  • implementation of S3 + CloudFront for faster fetching of static data
  • Least Privilege access to AWS resources

Thank you so much for reading! See you in the next post!

Top comments (0)