From 88aa178ccb1afb59de38db3cf75396f1468f64f6 Mon Sep 17 00:00:00 2001 From: eugenchio Date: Fri, 10 Jan 2025 14:24:54 +0200 Subject: [PATCH] Add newsletter sign up serverless function. --- .github/workflows/deploy.yaml | 2 + cloudformation.yaml | 159 ++++++++++++++++--------- deploy/settings.py | 8 ++ lambda-functions/newsletter_sign_up.py | 65 ++++++++++ lambda-functions/pyproject.toml | 1 + run.py | 98 ++++++++------- 6 files changed, 236 insertions(+), 97 deletions(-) create mode 100755 lambda-functions/newsletter_sign_up.py diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 08b4e1a..b380397 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -83,6 +83,8 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} PIPE_DRIVE_API_TOKEN: ${{ secrets.PIPE_DRIVE_API_TOKEN }} PIPE_DRIVE_LEAD_CUSTOM_DATA: ${{ secrets.PIPE_DRIVE_LEAD_CUSTOM_DATA }} + CRISP_TOKEN_ID: ${{ secrets.CRISP_TOKEN_ID }} + CRISP_TOKEN_KEY: ${{ secrets.CRISP_TOKEN_KEY }} run: uv run run.py deploy-lambda # Send notification on build or deploy failure diff --git a/cloudformation.yaml b/cloudformation.yaml index b92b216..84bfc06 100644 --- a/cloudformation.yaml +++ b/cloudformation.yaml @@ -139,7 +139,7 @@ Resources: - "ses:SendRawEmail" Resource: "*" - ContactFormLambda: + LandingContactFormLambda: Type: "AWS::Lambda::Function" Properties: Handler: "contact_form.lambda_handler" @@ -152,10 +152,10 @@ Resources: Runtime: "python3.9" Timeout: 10 - LandingContactFormLambda: + LandingNewsletterSignUpLambda: Type: "AWS::Lambda::Function" Properties: - Handler: "contact_form.lambda_handler" + Handler: "newsletter_sign_up.lambda_handler" Role: !GetAtt LambdaExecutionRole.Arn Code: # use CF feature - it compares yaml config with its previous version @@ -185,19 +185,6 @@ Resources: Properties: CloudWatchRoleArn: !GetAtt ApiGatewayLoggingRole.Arn - ContactFormAPIGateway: - Type: 'AWS::ApiGateway::RestApi' - DependsOn: - - ContactFormLambda - - ApiGatewayLoggingRole - Properties: - Name: ContactFormAPIGateway - FailOnWarnings: 'true' - Description: 'Contact Form API Gateway' - EndpointConfiguration: - Types: - - REGIONAL - LandingAPIGateway: Type: 'AWS::ApiGateway::RestApi' DependsOn: @@ -211,19 +198,19 @@ Resources: Types: - REGIONAL - ContactFormAPIMethod: + LandingContactFormAPIMethod: Type: 'AWS::ApiGateway::Method' Properties: AuthorizationType: NONE HttpMethod: ANY - ResourceId: !GetAtt ContactFormAPIGateway.RootResourceId - RestApiId: !Ref ContactFormAPIGateway + ResourceId: !GetAtt LandingAPIGateway.RootResourceId + RestApiId: !Ref LandingAPIGateway Integration: IntegrationHttpMethod: POST Type: AWS Uri: !Sub - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations - - LambdaArn: !GetAtt ContactFormLambda.Arn + - LambdaArn: !GetAtt LandingContactFormLambda.Arn IntegrationResponses: - StatusCode: 200 ResponseParameters: @@ -245,13 +232,13 @@ Resources: RequestModels: application/json: Empty - ContactFormAPIOptionsMethod: + LandingContactFormAPIOptionsMethod: Type: 'AWS::ApiGateway::Method' Properties: AuthorizationType: NONE HttpMethod: OPTIONS - ResourceId: !GetAtt ContactFormAPIGateway.RootResourceId - RestApiId: !Ref ContactFormAPIGateway + ResourceId: !GetAtt LandingAPIGateway.RootResourceId + RestApiId: !Ref LandingAPIGateway Integration: IntegrationResponses: - StatusCode: 200 @@ -272,12 +259,19 @@ Resources: method.response.header.Access-Control-Allow-Methods: true method.response.header.Access-Control-Allow-Headers: true - LandingContactFormAPIMethod: + LandingContactFormAPIResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !GetAtt LandingAPIGateway.RootResourceId + PathPart: 'contact' + RestApiId: !Ref LandingAPIGateway + + LandingNewContactFormAPIMethod: Type: 'AWS::ApiGateway::Method' Properties: AuthorizationType: NONE HttpMethod: ANY - ResourceId: !GetAtt LandingAPIGateway.RootResourceId + ResourceId: !Ref LandingContactFormAPIResource RestApiId: !Ref LandingAPIGateway Integration: IntegrationHttpMethod: POST @@ -306,12 +300,12 @@ Resources: RequestModels: application/json: Empty - LandingContactFormAPIOptionsMethod: + LandingNewContactFormAPIOptionsMethod: Type: 'AWS::ApiGateway::Method' Properties: AuthorizationType: NONE HttpMethod: OPTIONS - ResourceId: !GetAtt LandingAPIGateway.RootResourceId + ResourceId: !Ref LandingContactFormAPIResource RestApiId: !Ref LandingAPIGateway Integration: IntegrationResponses: @@ -333,33 +327,83 @@ Resources: method.response.header.Access-Control-Allow-Methods: true method.response.header.Access-Control-Allow-Headers: true - ContactFormAPIDeployment: - Type: 'AWS::ApiGateway::Deployment' - DependsOn: - - ContactFormAPIMethod + LandingNewsletterSignUpAPIResource: + Type: AWS::ApiGateway::Resource Properties: - RestApiId: !Ref ContactFormAPIGateway + ParentId: !GetAtt LandingAPIGateway.RootResourceId + PathPart: 'newsletter' + RestApiId: !Ref LandingAPIGateway + + LandingNewsletterSignUpAPIMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + AuthorizationType: NONE + HttpMethod: ANY + ResourceId: !Ref LandingNewsletterSignUpAPIResource + RestApiId: !Ref LandingAPIGateway + Integration: + IntegrationHttpMethod: POST + Type: AWS + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations + - LambdaArn: !GetAtt LandingNewsletterSignUpLambda.Arn + IntegrationResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: !Sub "'${CorsAllowedOrigins}'" + method.response.header.Access-Control-Allow-Methods: "'*'" + method.response.header.Access-Control-Allow-Headers: "'*'" + ResponseTemplates: + application/json: '{"status":"ok"}' + RequestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + MethodResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: true + method.response.header.Access-Control-Allow-Methods: true + method.response.header.Access-Control-Allow-Headers: true + RequestParameters: + method.request.header.Content-Type: false + RequestModels: + application/json: Empty + + LandingNewsletterSignUpAPIOptionsMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + AuthorizationType: NONE + HttpMethod: OPTIONS + ResourceId: !Ref LandingNewsletterSignUpAPIResource + RestApiId: !Ref LandingAPIGateway + Integration: + IntegrationResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: !Sub "'${CorsAllowedOrigins}'" + method.response.header.Access-Control-Allow-Methods: "'*'" + method.response.header.Access-Control-Allow-Headers: "'*'" + ResponseTemplates: + application/json: Empty + PassthroughBehavior: WHEN_NO_MATCH + RequestTemplates: + application/json: '{"statusCode": 200}' + Type: MOCK + MethodResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: true + method.response.header.Access-Control-Allow-Methods: true + method.response.header.Access-Control-Allow-Headers: true LandingAPIDeployment: Type: 'AWS::ApiGateway::Deployment' DependsOn: - LandingContactFormAPIMethod + - LandingNewContactFormAPIMethod + - LandingNewsletterSignUpAPIMethod Properties: RestApiId: !Ref LandingAPIGateway - # Enable logging of all HTTP requests - ContactFormAPIStage: - Type: AWS::ApiGateway::Stage - Properties: - DeploymentId: !Ref ContactFormAPIDeployment - MethodSettings: - - HttpMethod: '*' - LoggingLevel: INFO - ResourcePath: /* - DataTraceEnabled: true - RestApiId: !Ref ContactFormAPIGateway - StageName: !Ref apiGatewayStageName - # Enable logging of all HTTP requests LandingAPIStage: Type: AWS::ApiGateway::Stage @@ -374,16 +418,16 @@ Resources: StageName: !Ref apiGatewayStageName # Create permission for API Gateway to invoke Lambda - ContactFormAPIPermission: + LandingAPIPermission: Type: 'AWS::Lambda::Permission' Properties: Action: lambda:InvokeFunction - FunctionName: !Ref ContactFormLambda + FunctionName: !Ref LandingContactFormLambda Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ContactFormAPIGateway}/*/*/' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${LandingAPIGateway}/*/*/' # Create permission for API Gateway to invoke Lambda - LandingAPIPermission: + LandingContactFormAPIPermission: Type: 'AWS::Lambda::Permission' Properties: Action: lambda:InvokeFunction @@ -391,10 +435,19 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${LandingAPIGateway}/*/*/' + # Create permission for API Gateway to invoke Lambda + LandingNewsletterSignUpAPIPermission: + Type: 'AWS::Lambda::Permission' + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref LandingNewsletterSignUpLambda + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${LandingAPIGateway}/*/*/' + Outputs: - ContactFormInvokeURL: - Value: !Sub 'https://${ContactFormAPIGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName}/' - Description: URL for invoking the Contact Form API LandingContactFormInvokeURL: - Value: !Sub 'https://${LandingAPIGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName}/' + Value: !Sub 'https://${LandingAPIGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName}/contact' + Description: URL for invoking the Contact Form API + LandingNewsletterSignUpInvokeURL: + Value: !Sub 'https://${LandingAPIGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName}/newsletter' Description: URL for invoking the Contact Form API diff --git a/deploy/settings.py b/deploy/settings.py index 5067848..94a01d0 100644 --- a/deploy/settings.py +++ b/deploy/settings.py @@ -6,3 +6,11 @@ CONTACT_FORM_FROM_EMAIL = 'contact@new.ivelum.com' CONTACT_FORM_TO_EMAIL = 'info@ivelum.com' + +# WARNING: Crisp Development Token is connected to the PRODUCTION environment, +# since Crisp has no concept of test environment, so be careful. +# +# Development Token is limited to 500/requests day. +# If needed you can reset the limit here (see login credentials in Notion): +# https://marketplace.crisp.chat/plugins/plugin/421c7588-7ed5-464a-a1f7-8917ae9bafe7/tokens/ +CRISP_WEBSITE_ID = '6de2ec98-888b-4ff4-b9be-dab91a460ee6' diff --git a/lambda-functions/newsletter_sign_up.py b/lambda-functions/newsletter_sign_up.py new file mode 100755 index 0000000..3f7f9a7 --- /dev/null +++ b/lambda-functions/newsletter_sign_up.py @@ -0,0 +1,65 @@ +import json +import logging +import os +from json import JSONDecodeError + +import boto3 +import pipedrive +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +from crisp_api import Crisp + + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +sentry_sdk.init( + dsn="https://5dd58ec394b556f352248ba56bc7876f@o202054.ingest.sentry.io" + + "/4506218347954176", + integrations=[AwsLambdaIntegration()], + traces_sample_rate=1.0, + profiles_sample_rate=1.0, +) + + +def add_crisp_contact(event_body): + logger.info('add_crisp_contact(): start') + crisp = Crisp() + crisp.set_tier('plugin') + website_id = os.environ['CRISP_WEBSITE_ID'] + token_id = os.environ['CRISP_TOKEN_ID'] + token_key = os.environ['CRISP_TOKEN_KEY'] + + if not (website_id and token_id and token_key): + warnings.warn('Crisp credentials are missing!') + return + + crisp.authenticate(token_id, token_key) + + try: + contact_data = crisp.website.add_new_people_profile( + website_id=website_id, + data={ + 'email': event_body['email'], + 'person': {'nickname': event_body['email']}, + 'segments': 'newsletter', + }, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + logger.exception('add_crisp_contact(): add_crisp_contact failed') + return + + logger.info('add_crisp_contact(): success') + + +def lambda_handler(event): + assert event.get('email') is not None + + logger.info('lambda_handler(): invoked. event={}'.format(event)) + try: + add_crisp_contact(event) + except Exception as e: + sentry_sdk.capture_exception(e) + logger.exception('lambda_handler(): add_crisp_contact failed') diff --git a/lambda-functions/pyproject.toml b/lambda-functions/pyproject.toml index e8896d2..aacbafe 100644 --- a/lambda-functions/pyproject.toml +++ b/lambda-functions/pyproject.toml @@ -6,4 +6,5 @@ dependencies = [ "boto3==1.29.1", "requests==2.31.0", "sentry-sdk==1.35.0", + "crisp-api==1.1.19", ] diff --git a/run.py b/run.py index 815059c..2c8e9d0 100755 --- a/run.py +++ b/run.py @@ -32,57 +32,67 @@ def deploy_lambda(): run(f'zip -qr ../{code_archive_name} .', cwd=package_deps_path) run( - f'zip -qg {code_archive_name} pipedrive.py contact_form.py', + f'zip -qg {code_archive_name} pipedrive.py contact_form.py newsletter_sign_up.py', cwd=package_path, ) - function_name = resource_details( - settings.PROJECT_NAME, - 'LandingContactFormLambda', - )['PhysicalResourceId'] code_archive_path = f'fileb://{package_path}/{code_archive_name}' - aws( - f'lambda update-function-code ' - f'--function-name {function_name} ' - f'--zip-file {code_archive_path} ' - f'--publish', - ) - def get_function_config(): - return aws( - f'lambda get-function --function-name {function_name}', - )['Configuration'] - - while True: - function_config = get_function_config() - status = function_config['LastUpdateStatus'] - if status != 'InProgress': - break - time.sleep(1) - - if status == 'Failed': - status_reason = function_config['LastUpdateStatusReason'] - status_reason_code = function_config['LastUpdateStatusReasonCode'] - raise RuntimeError( - f'Failed to update lambda function. ' - f'Reason: {status_reason_code} | {status_reason}.', + functions = [ + 'LandingContactFormLambda', + 'LandingNewsletterSignUpLambda', + ] + + for function in functions: + function_name = resource_details( + settings.PROJECT_NAME, + function, + )['PhysicalResourceId'] + aws( + f'lambda update-function-code ' + f'--function-name {function_name} ' + f'--zip-file {code_archive_path} ' + f'--publish', ) - environment = { - 'Variables': { - 'CONTACT_FORM_FROM_EMAIL': settings.CONTACT_FORM_FROM_EMAIL, - 'CONTACT_FORM_TO_EMAIL': settings.CONTACT_FORM_TO_EMAIL, - 'PIPE_DRIVE_API_TOKEN': os.environ['PIPE_DRIVE_API_TOKEN'], - 'PIPE_DRIVE_LEAD_CUSTOM_DATA': - os.environ['PIPE_DRIVE_LEAD_CUSTOM_DATA'], - }, - } - aws( - f'lambda update-function-configuration ' - f'--function-name {function_name} ' - '--timeout 50 ' - f'--environment {quote(json.dumps(environment))}', - ) + def get_function_config(): + return aws( + f'lambda get-function --function-name {function_name}', + )['Configuration'] + + while True: + function_config = get_function_config() + status = function_config['LastUpdateStatus'] + if status != 'InProgress': + break + time.sleep(1) + + if status == 'Failed': + status_reason = function_config['LastUpdateStatusReason'] + status_reason_code = function_config['LastUpdateStatusReasonCode'] + raise RuntimeError( + f'Failed to update lambda function. ' + f'Reason: {status_reason_code} | {status_reason}.', + ) + + environment = { + 'Variables': { + 'CONTACT_FORM_FROM_EMAIL': settings.CONTACT_FORM_FROM_EMAIL, + 'CONTACT_FORM_TO_EMAIL': settings.CONTACT_FORM_TO_EMAIL, + 'PIPE_DRIVE_API_TOKEN': os.environ['PIPE_DRIVE_API_TOKEN'], + 'PIPE_DRIVE_LEAD_CUSTOM_DATA': + os.environ['PIPE_DRIVE_LEAD_CUSTOM_DATA'], + 'CRISP_WEBSITE_ID': settings.CRISP_WEBSITE_ID, + 'CRISP_TOKEN_ID': os.environ['CRISP_TOKEN_ID'], + 'CRISP_TOKEN_KEY': os.environ['CRISP_TOKEN_KEY'], + }, + } + aws( + f'lambda update-function-configuration ' + f'--function-name {function_name} ' + '--timeout 50 ' + f'--environment {quote(json.dumps(environment))}', + ) if __name__ == '__main__':