Skip to content

Commit

Permalink
handle ticket upgrades (#231)
Browse files Browse the repository at this point in the history
* add shared function for parsing events

* add ticket upgrade lambda

* add upgrade notification email template

* checkout handle upgrade

* bugfixes

* Upping ticket reminder timeout

* add shared function for parsing events

* add ticket upgrade lambda

* add upgrade notification email template

* checkout handle upgrade

* bugfixes

---------

Co-authored-by: Adam Bardsley <[email protected]>
  • Loading branch information
connorkm2 and bardsley authored Nov 6, 2024
1 parent d3f8421 commit cfc8864
Show file tree
Hide file tree
Showing 7 changed files with 478 additions and 530 deletions.
19 changes: 19 additions & 0 deletions functions/checkout_complete/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ def lambda_handler(event, context):
'body': json.dumps({'error': 'Checkout session does not exist'})
}

if stripe_response['metadata'].get('ticket_upgrade', False):
ticket_number = stripe_response.get('client_reference_id', None)
full_name = next((item for item in stripe_response['custom_fields'] if item["key"] == "fullname"), {})['text'].get('value', "unknown")
payload = {
'ticket_number': ticket_number,
'source': "stripe_checkout",
'upgrade_type': stripe_response['metadata'].get('upgrade_type', 'unknown'),
'full_name': full_name
}

# upgrade the ticket
logger.info("Invoking ticket_upgrade lambda")
response = lambda_client.invoke(
FunctionName=os.environ.get("TICKET_UPGRADE_LAMBDA"),
InvocationType='Event',
Payload=json.dumps(payload, cls=shared.DecimalEncoder),
)
logger.info(response)

access, line_items = process_line_items(stripe_response['line_items'])
student_ticket = True if stripe_response['line_items']['data'][0]['price']['nickname' ] == "student_active" else False
meal = json.loads(stripe_response['metadata']['preferences']) if 'preferences' in stripe_response['metadata'] else None
Expand Down
675 changes: 145 additions & 530 deletions functions/send_email/example.html

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions functions/send_email/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,23 @@ def lambda_handler(event, context):
body = event['message_body']
subject = event['subject']
attachment = None

elif event['email_type'] == "ticket_upgrade_notification":

subject = "Merseyside Latin Festival - Ticket Upgrade Confirmation"
subdomain = "www" if os.environ.get("STAGE_NAME") == "prod" else os.environ.get("STAGE_NAME")
manage_ticket_link = "http://{}.merseysidelatinfestival.co.uk/preferences?email={}&ticket_number={}".format(subdomain, event['email'], event['ticket_number'])

upgrade_details = "Your ticket has been upgraded to include: {}".format(event['upgrade_details'].replace("_", " "))

with open("./send_email/upgrade_notification.html", "r") as body_file:
body_tmpl = Template(body_file.read())
subdomain = "www" if os.environ.get("STAGE_NAME") == "prod" else os.environ.get("STAGE_NAME")
body = body_tmpl.substitute({
'upgrade_details': upgrade_details,
'ticket_link':manage_ticket_link,
})
attachment = None
else:
return False

Expand Down
45 changes: 45 additions & 0 deletions functions/send_email/upgrade_notification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<table class="module" role="module" data-type="text" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;">
<tbody>
<tr>
<td style="padding:30px 0px 40px 0px; line-height:22px; text-align:inherit;" height="100%" valign="top" bgcolor="" role="module-content">
<div>
<div style="font-family: inherit; text-align: center">
<span style="color: #80817f; font-size: 24px"><strong>Ticket Upgrade Notification</strong></span>
</div>
<div></div>
</div>
</td>
</tr>
</tbody>
</table>
<table class="module" role="module" data-type="text" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;">
<tbody>
<tr>
<td style="padding:0px 40px 40px 40px; line-height:22px; text-align:inherit;" height="100%" valign="top" bgcolor="" role="module-content">
<div>
<div style="font-family: inherit; text-align: inherit">
<!-- <span style="color: #80817f; font-size: 16px"><strong>Details:</strong></span> -->
<span style="color: #80817f; font-size: 16px"> $upgrade_details</span>
</div>
</div>
</td>
</tr>
</tbody>
<tbody>
<tr>
<td align="center" bgcolor="" class="outer-td" style="padding:0px 0px 0px 0px;">
<table border="0" cellpadding="0" cellspacing="0" class="wrapper-mobile" style="text-align:center;">
<tbody>
<tr>
<td align="center" bgcolor="#ffecea" class="inner-td" style="border-radius:6px; font-size:24px; text-align:center; background-color:inherit;">
<a href="$ticket_link" style="background-color:#2ED1FF; border:1px solid #2ED1FF; border-color:#2ED1FF; border-radius:6px; border-width:1px; color:#ffffff; display:inline-block; font-size:12px; font-weight:700; letter-spacing:0px; line-height:normal; padding:12px 40px 12px 40px; text-align:center; text-decoration:none; border-style:solid; font-family:inherit;" target="_blank">
<h1>MANAGE MY TICKET</h1>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
20 changes: 20 additions & 0 deletions functions/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ functions:
STRIPE_SECRET_KEY: ${param:stripeSecretKey}
CREATE_TICKET_LAMBDA: "${sls:stage}-create_ticket" #! Can't find a way to set this other than as a params that gets used byt function and the function itself
ATTENDEE_GROUPS_LAMBDA: "${sls:stage}-attendee_groups"
TICKET_UPGRADE_LAMBDA: "${sls:stage}-ticket_upgrade"
layers:
- !Ref StripeLambdaLayer
# - !Ref DynamodbLambdaLayer
Expand Down Expand Up @@ -556,4 +557,23 @@ functions:
path: /meal/summary
method: get

#--------------------

TicketUpgrade:
runtime: python3.11
handler: ticket_upgrade/lambda_function.lambda_handler
name: "${sls:stage}-ticket_upgrade"
package:
patterns:
- '!**/**'
- "ticket_upgrade/**"
- "shared/**"
environment:
STAGE_NAME: ${sls:stage}
EVENT_TABLE_NAME: ${param:eventTableName}
ATTENDEES_TABLE_NAME: ${param:attendeesTableName}
SEND_EMAIL_LAMBDA: "${sls:stage}-send_email"
events:
- httpApi:
path: /ticket_upgrade
method: post
43 changes: 43 additions & 0 deletions functions/shared/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import json
import logging

logger = logging.getLogger()
logger.setLevel("INFO")

def parse_event(event):
'''
Parses the input event and ensures it is returned as a dictionary.
'''
try:
# Check if event is a dictionary with a 'body' (HTTP POST case)
if isinstance(event, dict) and 'body' in event:
logger.info("Parsing event from HTTP POST request body")
event = json.loads(event['body'])

# Check if event is a JSON string
elif isinstance(event, str):
logger.info("Parsing event from JSON string")
event = json.loads(event)

# Validate that the event is now a dictionary
if not isinstance(event, dict):
logger.error("Event is not a dictionary after parsing")
raise TypeError("Event must be a dictionary or a valid JSON string")

except (json.JSONDecodeError, TypeError) as e:
logger.error("Event parsing error: %s", str(e))
raise ValueError("Invalid event format or JSON structure")

return event

def validate_event(event, required_fields):
'''
Validates that the necessary fields are present in the event.
'''
missing_fields = [field for field in required_fields if field not in event]

if missing_fields:
logger.error("Missing required fields: %s", missing_fields)
raise KeyError(f"Missing required fields: {missing_fields}")

return event
190 changes: 190 additions & 0 deletions functions/ticket_upgrade/lambda_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import json
import logging
import os
import time

import boto3
from boto3.dynamodb.conditions import Key
from shared.DecimalEncoder import DecimalEncoder
from shared.parser import parse_event, validate_event

#ENV
attendees_table_name = os.environ.get("ATTENDEES_TABLE_NAME")
event_table_name = os.environ.get("EVENT_TABLE_NAME")
send_email_lambda = os.environ.get("SEND_EMAIL_LAMBDA")

logger = logging.getLogger()
logger.setLevel("INFO")

db = boto3.resource('dynamodb')
attendees_table = db.Table(attendees_table_name)
event_table = db.Table(event_table_name)

lambda_client = boto3.client('lambda')

UPGRADE_MAP = {
'volunteer_meal': [2]
}

def record_failed_upgrade(event, reason):
'''
Records details of a failed upgrade attempt
'''
logger.info("Recording failed upgrade attempt.")
current_time = int(time.time())
pk = f"UPGRADEFAIL#{event.get('ticket_number', 'unknown')}"
sk = f"ERROR#{current_time}"

backup_entry = {
'PK': pk,
'SK': sk,
'timestamp': current_time,
'email': event.get('email', 'unknown'),
'full_name': event.get('full_name', 'unknown'),
'reason': reason,
'event_details': json.dumps(event, cls=DecimalEncoder)
}

try:
event_table.put_item(Item=backup_entry)
logger.info("Failed upgrade recorded successfully.")
except boto3.exceptions.Boto3Error as e:
logger.error("Error recording failed attempt: %s", str(e))


def is_upgrade_possible(current_access, upgrade_type):
'''
Check if an upgrade is possible based on the current access array and upgrade type
'''
upgrade_positions = UPGRADE_MAP.get(upgrade_type, [])
for pos in upgrade_positions:
if current_access[pos] == 0:
return True
return False

def apply_upgrade(current_access, upgrade_type):
'''
Apply the upgrade to the access array
'''
upgrade_positions = UPGRADE_MAP.get(upgrade_type, [])
for pos in upgrade_positions:
current_access[pos] = 1
return current_access

def lambda_handler(event, context):
# get ticket being upgraded
# what did they purchase?
# check upgrade is possible
# upgrade the ticket
# send confirmation email

# if can't find the ticket being upgraded:
# search again but using email
# using context see if it makes sense if any found are

try:
event = parse_event(event)
event = validate_event(event, ['ticket_number', 'upgrade_type', 'source'])
except (ValueError, TypeError, KeyError) as e:
logger.error("Event validation failed: %s", str(e))
logger.error(event)
return {
'statusCode': 400,
'body': f'Invalid input: {str(e)}'
}


logger.info("#### UPGRADING TICKET ####")
logger.info("Event received: %s", json.dumps(event, indent=2, cls=DecimalEncoder))

ticket_number = event['ticket_number']
upgrade_type = event['upgrade_type']
source = event.get('source', 'unknown')
full_name = event.get('full_name', 'unknown')
logger.info("Processing upgrade for ticket number: %s, upgrade type: %s, source: %s", ticket_number, upgrade_type, source)

if upgrade_type not in UPGRADE_MAP:
logger.warning("Unsupported upgrade type: %s", upgrade_type)
record_failed_upgrade(event, f"Unsupported upgrade type: {upgrade_type}")

return {
'statusCode': 400,
'body': f'Unsupported upgrade type: {upgrade_type}'
}


response = attendees_table.query(
IndexName="ticket_number-index",
KeyConditionExpression=Key('ticket_number').eq(ticket_number)
)

if 'Items' not in response or not response['Items']:
logger.warning("No ticket found with the provided ticket number")
record_failed_upgrade(event, "Ticket not found")
return {
'statusCode': 404,
'body': 'Ticket not found'
}


ticket = response['Items'][0]
logger.info("Ticket found: %s", json.dumps(ticket, indent=2, cls=DecimalEncoder))

current_access = ticket.get('access', [])
if not is_upgrade_possible(current_access, upgrade_type):
logger.warning("Upgrade not possible for this ticket")
record_failed_upgrade(event, "Upgrade not possible, access clash")
return {
'statusCode': 400,
'body': 'Upgrade not allowed'
}

try:
ticket['access'] = apply_upgrade(current_access, upgrade_type)

if ticket.get('history', []) == None:
ticket['history'] = []

ticket['history'] = ticket.get('history', []) + [{
"timestamp": int(time.time()),
"action": "upgrade",
"type": upgrade_type,
"source": source # Include source of the upgrade in the history
}]

logger.info("Updating ticket in DynamoDB...")
attendees_table.put_item(Item=ticket)
logger.info("Ticket upgrade successful")

except boto3.exceptions.Boto3Error as e:
logger.error("Failed to update ticket in DynamoDB: %s", str(e))
record_failed_upgrade(event, f"Failed to update ticket: {str(e)}")
return {
'statusCode': 500,
'body': 'Failed to update ticket in database'
}

if event.get('send_confirmation', True):
try:
logger.info("Invoking send_email lambda function")
response = lambda_client.invoke(
FunctionName=send_email_lambda,
InvocationType='Event',
Payload=json.dumps({
'email_type': "ticket_upgrade_notification",
'name': ticket['full_name'],
'email': ticket['email'],
'ticket_number': ticket_number,
'upgrade_details': upgrade_type
}, cls=DecimalEncoder),
)
logger.info("Email invocation response: %s", response)

except boto3.exceptions.Boto3Error as e:
logger.error("Failed to invoke send_email lambda: %s", str(e))
record_failed_upgrade(event, f"Failed to send confirmation email: {str(e)}")

return {
'statusCode': 200,
'body': 'Ticket upgraded successfully'
}

0 comments on commit cfc8864

Please sign in to comment.