Skip to content

Commit

Permalink
Merge pull request #92 from voynow/91-email-on-new-activity
Browse files Browse the repository at this point in the history
email on new activity
  • Loading branch information
voynow authored Oct 9, 2024
2 parents eae2d3f + cd3d688 commit bef3c95
Show file tree
Hide file tree
Showing 11 changed files with 300 additions and 183 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,5 @@ build/
Packages/


tests/artifacts
tests/artifacts
.vercel
31 changes: 29 additions & 2 deletions src/auth_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import jwt
from dotenv import load_dotenv
from stravalib.client import Client
from stravalib.model import Athlete

from src.supabase_client import get_user_auth, upsert_user_auth
from src.email_manager import send_alert_email
from src.supabase_client import get_user_auth, upsert_user, upsert_user_auth
from src.types.user_auth_row import UserAuthRow
from src.types.user_row import UserRow

load_dotenv()
strava_client = Client()
Expand Down Expand Up @@ -123,3 +124,29 @@ def get_strava_client(athlete_id: int) -> Client:
"""Interface for retrieving a Strava client with valid authentication"""
user_auth = authenticate_athlete(athlete_id)
return get_configured_strava_client(user_auth)


def signup(email: str, code: str) -> dict:
"""
Get authenticated user, upsert user with email and preferences
:param email: user email
:param code: strava code
:return: jwt_token
"""
preferences = (
"Looking for smart training recommendations to optimize my performance."
)
send_alert_email(
subject="TrackFlow Alert: New Signup Attempt",
text_content=f"You have a new client {email=} attempting to signup with {preferences=}",
)
user_auth = authenticate_with_code(code)
upsert_user(
UserRow(
athlete_id=user_auth.athlete_id,
email=email,
preferences=preferences,
)
)
return {"success": True, "jwt_token": user_auth.jwt_token}
71 changes: 71 additions & 0 deletions src/frontend_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from typing import Callable, Dict, Optional

import jwt

from src import auth_manager
from src.supabase_client import (
get_training_week,
get_user,
update_preferences,
)


def get_training_week_handler(athlete_id: str, payload: Optional[dict] = None) -> dict:
"""Handle get_training_week request."""
training_week = get_training_week(athlete_id)
return {
"success": True,
"training_week": training_week.json(),
}


def get_profile_handler(athlete_id: str, payload: Optional[dict] = None) -> dict:
"""Handle get_profile request."""
user = get_user(athlete_id)
athlete = auth_manager.get_strava_client(athlete_id).get_athlete()
return {
"success": True,
"profile": {
"firstname": athlete.firstname,
"lastname": athlete.lastname,
"profile": athlete.profile,
"email": user.email,
"preferences": user.preferences_json.json(),
"is_active": user.is_active,
},
}


def update_preferences_handler(athlete_id: str, payload: Optional[dict] = None) -> dict:
"""Handle update_preferences request."""
if payload is None or "preferences" not in payload:
return {"success": False, "error": "Missing preferences in payload"}
update_preferences(athlete_id=athlete_id, preferences_json=payload["preferences"])
return {"success": True}


METHOD_HANDLERS: Dict[str, Callable[[str, Optional[dict]], dict]] = {
"get_training_week": get_training_week_handler,
"get_profile": get_profile_handler,
"update_preferences": update_preferences_handler,
}


def handle_request(jwt_token: str, method: str, payload: Optional[dict] = None) -> dict:
"""
Handle various requests based on the provided method.
:param jwt_token: JWT token for authentication
:param method: The method to be executed
:param payload: Optional dictionary with additional data
:return: Dictionary with the result of the operation
"""
try:
athlete_id = auth_manager.decode_jwt(jwt_token)
except jwt.DecodeError:
return {"success": False, "error": "Invalid JWT token"}

if method in METHOD_HANDLERS:
return METHOD_HANDLERS[method](athlete_id, payload)
else:
return {"success": False, "error": f"Invalid method: {method}"}
138 changes: 7 additions & 131 deletions src/lambda_function.py
Original file line number Diff line number Diff line change
@@ -1,153 +1,30 @@
import logging
import os
import uuid
from typing import Optional

import jwt

from src.auth_manager import authenticate_with_code, decode_jwt, get_strava_client
from src.daily_pipeline import daily_executor, webhook_executor
from src import auth_manager, frontend_router, update_pipeline, webhook_router
from src.email_manager import send_alert_email
from src.supabase_client import (
get_training_week,
get_user,
list_users,
update_preferences,
upsert_user,
)
from src.types.user_row import UserRow

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


def signup(email: str, code: str) -> dict:
"""
Get authenticated user, upsert user with email and preferences
:param email: user email
:param code: strava code
:return: jwt_token
"""
preferences = (
"Looking for smart training recommendations to optimize my performance."
)
send_alert_email(
subject="TrackFlow Alert: New Signup Attempt",
text_content=f"You have a new client {email=} attempting to signup with {preferences=}",
)
user_auth = authenticate_with_code(code)
upsert_user(
UserRow(
athlete_id=user_auth.athlete_id,
email=email,
preferences=preferences,
)
)
return {"success": True, "jwt_token": user_auth.jwt_token}


def handle_frontend_request(
jwt_token: str, method: str, payload: Optional[dict] = None
) -> dict:
"""
To be extended for other requests eventually
Validate JWT, then return training week with coaching
:param jwt_token: jwt_token
:param method: method
:param payload: optional dictionary with additional data
:return: dict with {"success": bool}
"""
try:
athlete_id = decode_jwt(jwt_token)
except jwt.DecodeError:
return {"success": False, "error": "Invalid JWT token"}

if method == "get_training_week":
training_week = get_training_week(athlete_id)
return {
"success": True,
"training_week": training_week.json(),
}
elif method == "get_profile":
user = get_user(athlete_id)
athlete = get_strava_client(athlete_id).get_athlete()
return {
"success": True,
"profile": {
"firstname": athlete.firstname,
"lastname": athlete.lastname,
"profile": athlete.profile,
"email": user.email,
"preferences": user.preferences_json.json(),
"is_active": user.is_active,
},
}
elif method == "update_preferences":
update_preferences(
athlete_id=athlete_id, preferences_json=payload["preferences"]
)
return {"success": True}
else:
return {"success": False, "error": f"Invalid method: {method}"}


def handle_strava_webhook(event: dict) -> dict:
"""
Handle Strava webhook events for activities and athletes.
:param event: Webhook event payload from Strava
:return: dict with {"success": bool}
"""
subscription_id = int(event.get("subscription_id"))
expected_subscription_id = int(os.environ["STRAVA_WEBHOOK_SUBSCRIPTION_ID"])
if subscription_id != expected_subscription_id:
return {
"success": False,
"error": f"Invalid subscription ID: {event.get('subscription_id')}",
}

if event.get("object_type") == "activity":
if event.get("aspect_type") == "create":
return webhook_executor(get_user(event.get("owner_id")))
elif event.get("aspect_type") == "update":
return {
"success": True,
"message": f"Activity {event.get('object_id')} updated",
}
elif event.get("aspect_type") == "delete":
return {
"success": True,
"message": f"Activity {event.get('object_id')} deleted",
}
return {"success": False, "error": f"Unknown event type: {event}"}


def daily_exe_orchestrator() -> dict:
for user in list_users():
if user.is_active:
daily_executor(user)
return {"success": True}


def strategy_router(event: dict) -> dict:

# Will fail on bad authenticate_with_code
if event.get("email") and event.get("code"):
return signup(
return auth_manager.signup(
email=event["email"],
code=event["code"],
)

# Will fail on bad authenticate_with_code
elif event.get("code"):
user_auth = authenticate_with_code(event["code"])
user_auth = auth_manager.authenticate_with_code(event["code"])
return {"success": True, "jwt_token": user_auth.jwt_token}

elif event.get("jwt_token") and event.get("method"):
return handle_frontend_request(
return frontend_router.handle_request(
jwt_token=event["jwt_token"],
method=event["method"],
payload=event.get("payload"),
Expand All @@ -160,18 +37,17 @@ def strategy_router(event: dict) -> dict:
and event.get("object_id")
and event.get("owner_id")
):
return handle_strava_webhook(event)
return webhook_router.handle_request(event)

# This will only run if triggered by NIGHTLY_EMAIL_TRIGGER_ARN
elif (
event.get("resources")
and event.get("resources")[0] == os.environ["NIGHTLY_EMAIL_TRIGGER_ARN"]
):
return daily_exe_orchestrator()
return update_pipeline.nightly_trigger_orchestrator()

elif event.get("trigger_test_key") == os.environ["TRIGGER_TEST_KEY"]:
daily_executor(get_user(os.environ["JAMIES_ATHLETE_ID"]))
return {"success": True}
return update_pipeline.integration_test_executor()
else:
return {"success": False, "error": f"Unknown event type: {event}"}

Expand Down
29 changes: 29 additions & 0 deletions src/supabase_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime
import json
import os
from datetime import timedelta, timezone

from dotenv import load_dotenv
from postgrest.base_request_builder import APIResponse
Expand Down Expand Up @@ -218,3 +219,31 @@ def update_preferences(athlete_id: int, preferences_json: dict) -> APIResponse:
.execute()
)
return response


def has_user_updated_today(athlete_id: int) -> bool:
"""
Check if the user has received an update today. Where "today" is defined as
within the past 23 hours and 30 minutes (to account for any delays in
yesterday's evening update).
:param athlete_id: The ID of the athlete
:return: True if the user has received an update today, False otherwise
"""
table = client.table("training_week")
response = (
table.select("*")
.eq("athlete_id", athlete_id)
.order("created_at", desc=True)
.limit(1)
.execute()
)

if not response.data:
return False

# "Has this user posted an activity in the last 23 hours and 30 minutes?"
time_diff = datetime.now(timezone.utc) - datetime.fromisoformat(
response.data[0]["created_at"]
)
return time_diff < timedelta(hours=23, minutes=30)
10 changes: 10 additions & 0 deletions src/types/update_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from strenum import StrEnum


class ExeType(StrEnum):
NEW_WEEK = "new_week"
MID_WEEK = "mid_week"


class TrainingWeekUpdateError(Exception):
pass
Loading

0 comments on commit bef3c95

Please sign in to comment.