diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..c896e3a --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,41 @@ +name: Deploy API Docs to GitHub Pages + +on: + push: + branches: + - main + - N3_JP + paths: + - 'docs/**' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Notification/dependencies/python/user_fridge_notifications_api.py b/Notification/dependencies/python/user_fridge_notifications_api.py deleted file mode 100644 index 1f080fe..0000000 --- a/Notification/dependencies/python/user_fridge_notifications_api.py +++ /dev/null @@ -1,134 +0,0 @@ -from boto3.dynamodb.types import TypeSerializer -from boto3.dynamodb.types import TypeDeserializer -from datetime import datetime, timezone -try: - # Preferred: relative import when running inside SAM/local container - from user_fridge_notifications_model import UserFridgeNotificationModel -except ModuleNotFoundError: - # Fallback: absolute import when package is installed for tests - from Notification.dependencies.python.user_fridge_notifications_model import UserFridgeNotificationModel -import json -from botocore.exceptions import ClientError -import logging - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -class ApiResponse: - - def __init__(self, status_code: int, body: dict): - self.status_code = status_code - self.body = body - - def api_format(self) -> dict: - return { - "statusCode": self.status_code, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }, - "body": json.dumps(self.body) - } - -class UserFridgeNotificationApi: - #TODO: add auth so that only the user is able to edit their own preferences - def __init__(self, db_client: "botocore.client.DynamoDB"): - self.db_client = db_client - - def get_user_fridge_notification(self, user_id: str, fridge_id: str) -> ApiResponse: - key = {"user_id": {"S": user_id}, "fridge_id": {"S": fridge_id}} - try: - result = self.db_client.get_item(TableName=UserFridgeNotificationModel.TABLE_NAME, Key=key) - if "Item" not in result: - return ApiResponse( - status_code=404, body={"message": "Item not found"} - ) - else: - json_data = dynamodb_to_dict(result["Item"]) - return ApiResponse( - status_code=200, - body=json_data, - ) - except ClientError as e: - logger.error("DynamoDB error during GET: %s", e, exc_info=True) - return ApiResponse(status_code=500, body={"message": "Database error"}) - - - def post_user_fridge_notification(self, user_notification_model: UserFridgeNotificationModel) -> ApiResponse: - current_time = datetime.now(timezone.utc) - user_notification_model.created_at = current_time - user_notification_model.updated_at = current_time - dict_format = user_notification_model.model_dump(mode="json") - serialized_data = dict_to_dynamodb(dict_format) - try: - self.db_client.put_item( - TableName=UserFridgeNotificationModel.TABLE_NAME, - Item=serialized_data, - ConditionExpression="attribute_not_exists(user_id) AND attribute_not_exists(fridge_id)" - ) - return ApiResponse(status_code=201, body=dict_format) - except ClientError as e: - error_code = e.response['Error'].get('Code') - if error_code == "ConditionalCheckFailedException": - error_message = f"UserFridgeNotification with user_id: {user_notification_model.user_id}, and fridge_id: {user_notification_model.fridge_id} already exists" - return ApiResponse(status_code=409, body={"message": error_message}) - logger.error("DynamoDB error during POST: %s", e, exc_info=True) - return ApiResponse(status_code=500, body={"message": "Database Error"}) - - def put_user_fridge_notification(self, user_notification_model: UserFridgeNotificationModel) -> ApiResponse: - """Update existing user fridge notification preferences""" - key = { - "user_id": {"S": user_notification_model.user_id}, - "fridge_id": {"S": user_notification_model.fridge_id} - } - try: - result = self.db_client.get_item(TableName=UserFridgeNotificationModel.TABLE_NAME, Key=key) - if "Item" not in result: - return ApiResponse( - status_code=404, - body={"message": "User Fridge Notification not found"} - ) - - #update timestamp - current_time = datetime.now(timezone.utc) - expected_updated_at = result["Item"].get("updated_at", {}).get("S", None)#used to avoid race conditions - user_notification_model.updated_at = current_time - - #keep original created_at. Unless not set, which should not be the case - existing_item = dynamodb_to_dict(result["Item"]) - created_at_str = existing_item.get("created_at") - if created_at_str and isinstance(created_at_str, str): - #created_at is of type str, but model expects type datetime. So need to convert from string -> datetime - user_notification_model.created_at = datetime.fromisoformat(created_at_str) - else: - user_notification_model.created_at = current_time - - #serialize and update - dict_format = user_notification_model.model_dump(mode="json") - - serialized_data = dict_to_dynamodb(dict_format) - - self.db_client.put_item( - TableName=UserFridgeNotificationModel.TABLE_NAME, - Item=serialized_data, - ConditionExpression="updated_at = :expected_updated_at", - ExpressionAttributeValues={":expected_updated_at": {"S": expected_updated_at}} - ) - - return ApiResponse(status_code=200, body=dict_format) - except ClientError as e: - error_code = e.response['Error'].get('Code') - if error_code == "ConditionalCheckFailedException": - return ApiResponse(status_code=409, body={"message": "Update conflict, please retry"}) - logger.error("DynamoDB error during PUT: %s", e, exc_info=True) - return ApiResponse(status_code=500, body={"message": "Database Error"}) - - -def dict_to_dynamodb(data: dict) -> dict: - serializer = TypeSerializer() - return {k: serializer.serialize(v) for k, v in data.items()} - -def dynamodb_to_dict(ddb_item: dict) -> dict: - deserializer = TypeDeserializer() - return {k: deserializer.deserialize(v) for k, v in ddb_item.items()} - diff --git a/Notification/dependencies/python/user_fridge_notifications_model.py b/Notification/dependencies/python/user_fridge_notifications_model.py deleted file mode 100644 index 442e9a9..0000000 --- a/Notification/dependencies/python/user_fridge_notifications_model.py +++ /dev/null @@ -1,131 +0,0 @@ -from pydantic import BaseModel, field_validator, Field, model_validator, ConfigDict -from typing import ClassVar -from enum import Enum -import phonenumbers -import boto3 -import os -from email_validator import validate_email -from typing import Optional -from datetime import datetime -import logging - -logger = logging.getLogger() -logger.setLevel(logging.INFO) - -def get_ddb_connection(env: str = os.getenv("Environment", "")) -> "botocore.client.DynamoDB": - ddbclient = "" - if env == "local": - ddbclient = boto3.client("dynamodb", endpoint_url="http://localstack:4566/") - else: - ddbclient = boto3.client("dynamodb") - return ddbclient - -class ContactTypeStatusEnum(Enum): - START = "start" - PAUSE = "pause" - STOP = "stop" - -class FridgePreferencesModel(BaseModel): - model_config = ConfigDict(extra="forbid") - good: bool - dirty: bool = True - out_of_order: bool = True - not_at_location: bool = True - ghost: bool = True - food_level_0: bool - food_level_1: bool - food_level_2: bool - food_level_3: bool - cleaned: bool #TODO: need to update fridge report to accept a "cleaned" report. - -class ContactTypePreferencesModel(BaseModel): - model_config = ConfigDict(extra="forbid") - email: Optional[FridgePreferencesModel] = None - sms: Optional[FridgePreferencesModel] = None - device: Optional[FridgePreferencesModel] = None - -class ContactInfoModel(BaseModel): - model_config = ConfigDict(extra="forbid") - email: Optional[str] = None - sms: Optional[str] = None - device: Optional[str] = None - -class ContactTypeStatusModel(BaseModel): - model_config = ConfigDict(extra="forbid") - email: Optional[ContactTypeStatusEnum] = None - sms: Optional[ContactTypeStatusEnum] = None - device: Optional[ContactTypeStatusEnum] = None - -class UserFridgeNotificationModel(BaseModel): - #NOTE: MIN_ID_LENGTH and MAX_ID_LENGTH is used in a different service as well, should maybe set as an environment field on aws - STAGE: ClassVar[str] = os.getenv("Stage") - TABLE_NAME: ClassVar[str] = f"user_fridge_notifications_{STAGE}" - MIN_ID_LENGTH: ClassVar[int] = 3 - MAX_ID_LENGTH: ClassVar[int] = 100 - user_id: str = Field(..., min_length = MIN_ID_LENGTH, max_length = MAX_ID_LENGTH) - fridge_id: str = Field(..., min_length = MIN_ID_LENGTH, max_length = MAX_ID_LENGTH) - contact_info: ContactInfoModel - contact_types_status: ContactTypeStatusModel - contact_types_preferences: ContactTypePreferencesModel - created_at: Optional[datetime] = Field(default=None) - updated_at: Optional[datetime] = Field(default=None) - #last_notified? - - @field_validator('contact_info') - @classmethod - def validate_contact_info(cls, contact_info: ContactInfoModel) -> ContactInfoModel: - if contact_info.email: - validate_email(contact_info.email) - if contact_info.sms: - validate_phone_number(phone_number=contact_info.sms) - if contact_info.device: - validate_device(device=contact_info.device) - return contact_info - - - @model_validator(mode="after") - def validate_contact_type_fields_are_set(self) -> "UserFridgeNotificationModel": - ### - #Validates that if email, sms, or device is set on any of these fields: - #contact_types_status, contact_types_preferences, contact_info - #then they must be on all of them - ### - field_names = ContactTypeStatusModel.model_fields.keys() - - for field in field_names: - values = [ - getattr(self.contact_types_status, field, None), - getattr(self.contact_types_preferences, field, None), - getattr(self.contact_info, field, None), - ] - - #If ANY of the values are set then ALL of the values must be set - if any(values) and not all(values): - raise ValueError( - f"Inconsistent {field}: all three (status, preferences, info) " - f"must be set or all must be unset. Got {values}" - ) - - return self - - -def validate_phone_number(phone_number: str) -> str: - try: - number = phonenumbers.parse(phone_number, None) - except phonenumbers.NumberParseException as e: - raise ValueError(f"Invalid phone number format: {e}") - - if not phonenumbers.is_valid_number(number): - raise ValueError("Phone number is not valid") - - if phonenumbers.region_code_for_number(number) != "US": - raise ValueError("Only US phone numbers are allowed") - - return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) - -def validate_device(device: str) -> str: - #TODO: validate device. Don't know if we'll need this but keeping it here as a reminder - #NOTE: for devices we should consider adding a separate devices table - #.. but device preferences remain the same across all devices. We will probably query on user_id to get all the user's devices - #.. but lets discuss when push notifications gets built out on mobile app - return device \ No newline at end of file diff --git a/Notification/events/get_notification.json b/Notification/events/get_notification.json deleted file mode 100644 index 9621b23..0000000 --- a/Notification/events/get_notification.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "resource": "/users/{user_id}/fridges/{fridge_id}", - "path": "/users/user_1/fridges/fridge_1", - "httpMethod": "GET", - "headers": { - "Authorization": "Bearer eyJraWQiOiJr..." - }, - "pathParameters": { - "user_id": "user_1", - "fridge_id": "fridge_1" - }, - "requestContext": { - "resourcePath": "/users/{user_id}/fridges/{fridge_id}", - "httpMethod": "GET", - "authorizer": { - "claims": { - "sub": "user_1", - "aud": "2vpj4slplvpdasfjfklm48h6m", - "email_verified": "true", - "event_id": "3b57c6ff-0472-4b23-9240-70ed20fbd437", - "token_use": "id", - "auth_time": "1713716028", - "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123XYZ", - "cognito:username": "user_1@example.com", - "exp": "1713719628", - "iat": "1713716028", - "email": "user_1@example.com" - } - }, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "sourceIp": "127.0.0.1", - "userAgent": "curl/8.1.2", - "userArn": null, - "cognitoAuthenticationType": null, - "user": null - } - }, - "body": null, - "isBase64Encoded": false -} diff --git a/Notification/events/post_notification.json b/Notification/events/post_notification.json deleted file mode 100644 index 1b76325..0000000 --- a/Notification/events/post_notification.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "resource": "/v1/users/{user_id}/notifications/{fridge_id}", - "path": "/v1/users/user_1/notifications/fridge_1", - "httpMethod": "POST", - "headers": { - "Content-Type": "application/json", - "Authorization": "Bearer eyJraWQiOiJr..." - }, - "pathParameters": { - "user_id": "user_1", - "fridge_id": "fridge_1" - }, - "requestContext": { - "resourcePath": "/v1/users/{user_id}/notifications/{fridge_id}", - "httpMethod": "POST", - "authorizer": { - "claims": { - "sub": "user_1", - "aud": "2vpj4slplvpdasfjfklm48h6m", - "email_verified": "true", - "event_id": "3b57c6ff-0472-4b23-9240-70ed20fbd437", - "token_use": "id", - "auth_time": "1713716028", - "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123XYZ", - "cognito:username": "user_1", - "exp": "1713719628", - "iat": "1713716028", - "email": "testuser@example.com" - } - }, - "identity": { - "sourceIp": "127.0.0.1", - "userAgent": "curl/8.1.2" - } - }, - "body": "{\n \"contact_types_status\": {\"sms\": \"start\"},\n \"contact_types_preferences\": {\"sms\": {\"good\": true, \"dirty\": true, \"out_of_order\": true, \"not_at_location\": true, \"ghost\": true, \"food_level_0\": false, \"food_level_1\": false, \"food_level_2\": true, \"food_level_3\": true, \"cleaned\": false}},\n \"contact_info\": {\"sms\": \"+18474088437\"}\n}", - "isBase64Encoded": false -} diff --git a/Notification/events/put_notification.json b/Notification/events/put_notification.json deleted file mode 100644 index 8ba2724..0000000 --- a/Notification/events/put_notification.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "resource": "/v1/users/{user_id}/notifications/{fridge_id}", - "path": "/v1/users/user_1/notifications/fridge_1", - "httpMethod": "PUT", - "headers": { - "Content-Type": "application/json", - "Authorization": "Bearer eyJraWQiOiJr..." - }, - "pathParameters": { - "user_id": "user_1", - "fridge_id": "fridge_1" - }, - "requestContext": { - "resourcePath": "/v1/users/{user_id}/notifications/{fridge_id}", - "httpMethod": "PUT", - "authorizer": { - "claims": { - "sub": "user_1", - "aud": "2vpj4slplvpdasfjfklm48h6m", - "email_verified": "true", - "event_id": "3b57c6ff-0472-4b23-9240-70ed20fbd437", - "token_use": "id", - "auth_time": "1713716028", - "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123XYZ", - "cognito:username": "user_1", - "exp": "1713719628", - "iat": "1713716028", - "email": "testuser@example.com" - } - }, - "identity": { - "sourceIp": "127.0.0.1", - "userAgent": "curl/8.1.2" - } - }, - "body": "{\n \"contact_types_status\": {\"sms\": \"stop\"},\n \"contact_types_preferences\": {\"sms\": {\"good\": true, \"dirty\": true, \"out_of_order\": true, \"not_at_location\": true, \"ghost\": true, \"food_level_0\": false, \"food_level_1\": false, \"food_level_2\": true, \"food_level_3\": true, \"cleaned\": false}},\n \"contact_info\": {\"sms\": \"+18474088437\"}\n}", - "isBase64Encoded": false -} diff --git a/Notification/functions/fridge_notifications/getAllUserNotifications/app.py b/Notification/functions/fridge_notifications/getAllUserNotifications/app.py deleted file mode 100644 index ba64292..0000000 --- a/Notification/functions/fridge_notifications/getAllUserNotifications/app.py +++ /dev/null @@ -1,11 +0,0 @@ -import json - -def lambda_handler(event, context): - - return { - "statusCode": 200, - "body": json.dumps({ - "message": "getAllUsers", - # "location": ip.text.replace("\n", "") - }), - } \ No newline at end of file diff --git a/Notification/functions/fridge_notifications/user/app.py b/Notification/functions/fridge_notifications/user/app.py deleted file mode 100644 index 05f05d5..0000000 --- a/Notification/functions/fridge_notifications/user/app.py +++ /dev/null @@ -1,77 +0,0 @@ -import os -import logging -import json - -try: - from user_fridge_notifications_model import get_ddb_connection, UserFridgeNotificationModel - from user_fridge_notifications_api import ApiResponse, UserFridgeNotificationApi -except ModuleNotFoundError: - # Fallback: absolute imports (works when package is installed / running tests) - from Notification.dependencies.python.user_fridge_notifications_model import get_ddb_connection, UserFridgeNotificationModel - from Notification.dependencies.python.user_fridge_notifications_api import ApiResponse, UserFridgeNotificationApi - -from pydantic import ValidationError - -env = os.environ["Environment"] -#initialized only once per container -db_client = get_ddb_connection(env) -logger = logging.getLogger() -logger.setLevel(logging.INFO) - -def lambda_handler(event, context): - # Extract Cognito claims - claims = event.get("requestContext", {}).get("authorizer", {}).get("claims", {}) - - authenticated_user_id = claims.get("sub") - httpMethod = event.get("httpMethod", None) - fridge_id = event.get("pathParameters", {}).get("fridge_id", None) - user_id = event.get("pathParameters", {}).get("user_id", None) - api = UserFridgeNotificationApi(db_client=db_client) - #NOTE: don't need user_id in pathParameters but if we ever want to allow for ADMIN users to - #... access other users' notifications we can keep it - # Enforce that the authenticated user can only access their own notifications - if not authenticated_user_id or (user_id and user_id != authenticated_user_id): - logger.warning(f"Unauthorized access attempt: path user_id={user_id}, auth user_id={authenticated_user_id}") - return ApiResponse(status_code=403, body={"message": "Forbidden: You are not authorized to access this resource."}).api_format() - - ### GET API - if httpMethod == "GET": - api_response = api.get_user_fridge_notification(user_id=user_id, fridge_id=fridge_id) - return api_response.api_format() - - ### POST / PUT API - body = event.get("body", None) - if body is None: - return ApiResponse(400, {"message": "Missing request body"}).api_format() - try: - user_fridge_notification_model = build_user_fridge_notification_model(body=body, user_id=user_id, fridge_id=fridge_id) - except ValidationError as ve: - return ApiResponse(status_code=400, body={"message": str(ve)}).api_format() - except ValueError as ve: - return ApiResponse(status_code=400, body={"message": str(ve)}).api_format() - if httpMethod == "POST": - api_response = api.post_user_fridge_notification(user_notification_model=user_fridge_notification_model) - return api_response.api_format() - - if httpMethod == "PUT": - api_response = api.put_user_fridge_notification(user_notification_model=user_fridge_notification_model) - return api_response.api_format() - - ### IF NONE OF THE ABOVE THEN THE HTTP METHOD IS INVALID - api_response = ApiResponse(status_code=400, body={"message": "invalid http method"}) - return api_response.api_format() - - -def build_user_fridge_notification_model(body: str, user_id: str, fridge_id: str) -> UserFridgeNotificationModel: - body_dict = json.loads(body) - contact_info = body_dict.get("contact_info", None) - contact_types_preferences = body_dict.get("contact_types_preferences", None) - contact_types_status = body_dict.get("contact_types_status", None) - user_fridge_notification_model = UserFridgeNotificationModel( - user_id=user_id, - fridge_id=fridge_id, - contact_info=contact_info, - contact_types_preferences=contact_types_preferences, - contact_types_status=contact_types_status - ) - return user_fridge_notification_model \ No newline at end of file diff --git a/Notification/functions/fridge_notifications/user/requirements.txt b/Notification/functions/fridge_notifications/user/requirements.txt deleted file mode 100644 index 3c7e992..0000000 --- a/Notification/functions/fridge_notifications/user/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Intentionally empty: shared deps (pydantic, email-validator, phonenumbers) now provided by CommonLayer. -# boto3 is provided by the Lambda runtime. -pydantic>=2.0 -email-validator>=2.1.0 -phonenumbers>=8.12.0 \ No newline at end of file diff --git a/Notification/samconfig.toml b/Notification/samconfig.toml deleted file mode 100644 index 62b04ae..0000000 --- a/Notification/samconfig.toml +++ /dev/null @@ -1,90 +0,0 @@ -# samconfig.toml - SAM CLI configuration for guided and non-interactive deploys -# Replace the placeholder values (Hosted Zone ID, region, stack_name, etc.) -# Usage: `sam deploy --config-file samconfig.toml --config-env default` - -version = 0.1 - -[default] -# Global settings that apply to all environments -s3_prefix = "cfm-notification/dev" - #stack_name = "cfm-notification-dev" - - -[default.package] -[default.package.parameters] - -[default.deploy] -[default.deploy.parameters] -# Region to deploy to -region = "us-east-1" -# IAM capability; use CAPABILITY_NAMED_IAM if your template creates named roles -capabilities = "CAPABILITY_NAMED_IAM" -# Whether to prompt for changeset confirmation -confirm_changeset = true -# Useful to avoid failing when there are no changes -no_fail_on_empty_changeset = false -stack_name = "cfm-notification-dev" -resolve_s3 = true - -# Template parameters defined in your template.yaml -parameter_overrides = [ - "Environment=aws", - "Stage=dev", - "CFMHostedZoneId=" -] - -tags = "project=FridgeFinder service=notifications" - - -########################### -# Staging environment -########################### - -[staging] -s3_prefix = "cfm-notification/staging" - #stack_name = "cfm-notification-staging" - -[staging.package] -[staging.package.parameters] - -[staging.deploy] -[staging.deploy.parameters] -region = "us-east-1" -capabilities = "CAPABILITY_NAMED_IAM" -confirm_changeset = true -no_fail_on_empty_changeset = false -stack_name = "cfm-notification-staging" -resolve_s3 = true -parameter_overrides = [ - "Environment=aws", - "Stage=staging", - "CFMHostedZoneId=" -] -tags = "project=FridgeFinder service=notifications env=staging" - - -########################### -# Production environment -########################### - -[prod] -s3_prefix = "cfm-notification/prod" - #stack_name = "cfm-notification-prod" - -[prod.package] -[prod.package.parameters] - -[prod.deploy] -[prod.deploy.parameters] -region = "us-east-1" -capabilities = "CAPABILITY_NAMED_IAM" -confirm_changeset = true -no_fail_on_empty_changeset = false -stack_name = "cfm-notification-prod" -resolve_s3 = true -parameter_overrides = [ - "Environment=aws", - "Stage=prod", - "CFMHostedZoneId=" -] -tags = "project=FridgeFinder service=notifications env=prod" diff --git a/Notification/template.yaml b/Notification/template.yaml deleted file mode 100644 index 3d06e17..0000000 --- a/Notification/template.yaml +++ /dev/null @@ -1,264 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - Notifications - - Sample SAM Template for FridgeFinder Notifications Service - -# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst -Globals: - Function: - Timeout: 60 - MemorySize: 256 - -Parameters: - Environment: - Type: String - Description: Choose between local or AWS - AllowedValues: - - local - - aws - Stage: - Type: String - Description: Choose between dev, staging, prod - AllowedValues: - - dev - - staging - - prod - CFMHostedZoneId: - Type: String - Description: Grab the HostedZoneId from Route53 console - - -Resources: - ApiCertificate: - Type: AWS::CertificateManager::Certificate - Properties: - DomainName: !Sub notifications-api-${Stage}.communityfridgefinder.com - DomainValidationOptions: - - DomainName: !Sub notifications-api-${Stage}.communityfridgefinder.com - HostedZoneId: !Sub ${CFMHostedZoneId} - ValidationMethod: DNS - ApiGatewayApi: - Type: AWS::Serverless::Api - Properties: - Description: "FridgeFinder Notifications API with Cognito Authorization" - StageName: !Ref Stage - # CORS preflight settings from https://vercel.com/guides/how-to-enable-cors - Cors: - AllowMethods: "'GET,OPTIONS,PATCH,DELETE,POST,PUT'" - AllowHeaders: "'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, - Content-MD5, Content-Type, Date, X-Api-Version'" - AllowOrigin: "'*'" - MaxAge: "'86400'" - AllowCredentials: false - EndpointConfiguration: REGIONAL - Auth: - Authorizers: - CognitoAuthorizer: - UserPoolArn: - Fn::ImportValue: !Sub "fridgefinder-auth-${Stage}-UserPoolArn" - Identity: - Header: Authorization - AuthorizerPayloadFormatVersion: "1.0" - Domain: - DomainName: !Sub notifications-api-${Stage}.communityfridgefinder.com - CertificateArn: !Ref ApiCertificate - Route53: - HostedZoneName: "communityfridgefinder.com." # NOTE: The period at the end is required - - ########################## - ## Lambda Functions ## - ########################## - HelloWorldFunction: - Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction - Properties: - CodeUri: functions/hello_world/ - Handler: app.lambda_handler - Runtime: python3.13 - Architectures: - - x86_64 - Events: - HelloWorld: - Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api - Properties: - Path: /hello - Method: get - RestApiId: - Ref: ApiGatewayApi - - UserFridgeNotificationsFunction: - Type: AWS::Serverless::Function - Properties: - CodeUri: functions/fridge_notifications/user - Handler: app.lambda_handler - Runtime: python3.13 - FunctionName: !Sub UserFridgeNotificationsFunction${Stage} - Environment: - Variables: - Environment: !Ref Environment - Stage: !Ref Stage - Layers: - - !Ref CommonLayer - Policies: - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* - - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - Resource: !GetAtt UserFridgeNotificationsTable.Arn - Events: - PostUserFridgeNotification: - Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api - Properties: - Path: /v1/users/{user_id}/notifications/{fridge_id} - Method: post - RestApiId: - Ref: ApiGatewayApi - Auth: - Authorizer: CognitoAuthorizer - PutUserFridgeNotification: - Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api - Properties: - Path: /v1/users/{user_id}/notifications/{fridge_id} - Method: put - RestApiId: - Ref: ApiGatewayApi - Auth: - Authorizer: CognitoAuthorizer - GetUserFridgeNotification: - Type: Api - Properties: - Path: /v1/users/{user_id}/notifications/{fridge_id} - Method: get - RestApiId: - Ref: ApiGatewayApi - Auth: - Authorizer: CognitoAuthorizer - RequestParameters: - - method.request.path.user_id: - Required: true - Caching: false - - method.request.path.fridge_id: - Required: true - Caching: false - - GetAllUserFridgeNotificationsFunction: - Type: AWS::Serverless::Function - Properties: - CodeUri: functions/fridge_notifications/getAllUserNotifications - Handler: app.lambda_handler - Runtime: python3.13 - FunctionName: !Sub GetAllUserFridgeNotificationsFunction${Stage} - Environment: - Variables: - Environment: !Ref Environment - Stage: !Ref Stage - Layers: - - !Ref CommonLayer - Policies: - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: arn:aws:logs:*:*:* - - Effect: Allow - Action: - - dynamodb:Scan - - dynamodb:Query - Resource: - - !GetAtt UserFridgeNotificationsTable.Arn - - !Sub "${UserFridgeNotificationsTable.Arn}/index/*" - Events: - GetAllUserFridgeNotifications: - Type: Api - Properties: - Path: /v1/users/{user_id}/fridges/notifications - Method: get - RestApiId: - Ref: ApiGatewayApi - Auth: - Authorizer: CognitoAuthorizer - RequestParameters: - - method.request.path.user_id: - Required: true - Caching: false - - ########################## - ## DynamoDB Tables ## - ########################## - - UserFridgeNotificationsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub user_fridge_notifications_${Stage} - BillingMode: "PAY_PER_REQUEST" - AttributeDefinitions: - - AttributeName: "user_id" - AttributeType: "S" - - AttributeName: "fridge_id" - AttributeType: "S" - KeySchema: - - AttributeName: "user_id" - KeyType: "HASH" - - AttributeName: "fridge_id" - KeyType: "RANGE" - GlobalSecondaryIndexes: - - IndexName: FridgeIndex - KeySchema: - - AttributeName: fridge_id - KeyType: HASH - - AttributeName: user_id - KeyType: RANGE - Projection: - ProjectionType: ALL - - ########################## - ## CommonLayer ## - ########################## - CommonLayer: - Type: AWS::Serverless::LayerVersion - Properties: - LayerName: !Sub FridgeNotificationsLayer${Stage} - Description: Dependencies for FridgeFinder Fridge Notifications service - ContentUri: dependencies/ - CompatibleRuntimes: - - python3.13 - RetentionPolicy: Delete - -Outputs: - # Find out more about other implicit resources you can reference within SAM - # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api - NotificationsApiBaseUrl: - Description: "Base URL for Notifications API (custom domain)" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/" - - NotificationsApiHelloWorld: - Description: "Hello World endpoint URL" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/hello" - - NotificationsApiGetUserFridgeNotification: - Description: "GET user fridge notification endpoint URL" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/v1/users/{user_id}/notifications/{fridge_id}" - - NotificationsApiPostUserFridgeNotification: - Description: "POST user fridge notification endpoint URL" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/v1/users/{user_id}/notifications/{fridge_id}" - - NotificationsApiPutUserFridgeNotification: - Description: "PUT user fridge notification endpoint URL" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/v1/users/{user_id}/notifications/{fridge_id}" - - NotificationsApiGetAllUserFridgeNotifications: - Description: "GET all user fridge notifications endpoint URL" - Value: !Sub "https://notifications-api-${Stage}.communityfridgefinder.com/v1/users/{user_id}/fridges/notifications" diff --git a/Notification/tests/unit/test_user_fridge_notifications_model.py b/Notification/tests/unit/test_user_fridge_notifications_model.py deleted file mode 100644 index 5c27786..0000000 --- a/Notification/tests/unit/test_user_fridge_notifications_model.py +++ /dev/null @@ -1,84 +0,0 @@ -import unittest -from datetime import datetime -from unittest.mock import patch -from Notification.dependencies.python.user_fridge_notifications_model import ( - UserFridgeNotificationModel, - ContactInfoModel, - ContactTypeStatusModel, - ContactTypePreferencesModel, - FridgePreferencesModel, - validate_phone_number, -) - -class TestUserFridgeNotificationModel(unittest.TestCase): - def setUp(self): - # reusable valid fridge preferences - self.fridge_prefs = FridgePreferencesModel( - good=True, - dirty=True, - out_of_order=True, - not_at_location=True, - ghost=True, - food_level_0=True, - food_level_1=True, - food_level_2=True, - food_level_3=True, - cleaned=True, - ) - - def test_model_happy_path(self): - contact_info = ContactInfoModel(email="test@example.com", sms="+14155552671") - contact_types_status = ContactTypeStatusModel(sms="start", email="start") - contact_types_preferences = ContactTypePreferencesModel(sms=self.fridge_prefs, email=self.fridge_prefs) - - # validate_email performs DNS checks; patch it during tests to avoid network calls - with patch("Notification.dependencies.python.user_fridge_notifications_model.validate_email", return_value={"email": "test@example.com"}): - model = UserFridgeNotificationModel( - user_id="user_123", - fridge_id="fridge_123", - contact_info=contact_info, - contact_types_status=contact_types_status, - contact_types_preferences=contact_types_preferences, - ) - - self.assertEqual(model.user_id, "user_123") - self.assertEqual(model.fridge_id, "fridge_123") - # phone should be normalized to E.164 - self.assertTrue(model.contact_info.sms.startswith("+1")) - - def test_inconsistent_fields_raise_value_error(self): - # contact_info.email set but preferences and status not set -> should raise - contact_info = ContactInfoModel(email="test@example.com") - contact_types_status = ContactTypeStatusModel() - contact_types_preferences = ContactTypePreferencesModel() - with patch("Notification.dependencies.python.user_fridge_notifications_model.validate_email", return_value={"email": "test@example.com"}): - with self.assertRaises(ValueError): - UserFridgeNotificationModel( - user_id="user_123", - fridge_id="fridge_123", - contact_info=contact_info, - contact_types_status=contact_types_status, - contact_types_preferences=contact_types_preferences, - ) - - def test_invalid_phone_raises(self): - contact_info = ContactInfoModel(email=None, sms="not-a-number") - contact_types_status = ContactTypeStatusModel(sms=None) - contact_types_preferences = ContactTypePreferencesModel() - - with self.assertRaises(ValueError): - UserFridgeNotificationModel( - user_id="user_123", - fridge_id="fridge_123", - contact_info=contact_info, - contact_types_status=contact_types_status, - contact_types_preferences=contact_types_preferences, - ) - - def test_validate_phone_number_function(self): - formatted = validate_phone_number("+14155552671") - self.assertEqual(formatted, "+14155552671") - - -if __name__ == "__main__": - unittest.main() diff --git a/README.md b/README.md index 3c7692c..387178a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ User's of FridgeFinder are able to receive notification on status updates of a C User's can Follow to a Community Fridge by going to a Fridge Profile and clicking on the Follow Button [TODO: implement follow button :)] - find one near you https://www.fridgefinder.app/browse -Currently User's can receive fridge notification via Email or SMS +Currently User's can receive fridge notification via Email or Device Push Notification --- ## Pre-Requisites @@ -70,7 +70,7 @@ Follow these steps to get Dynamodb running locally Confirm that the following requests work for you -1. `cd Notification/` +1. `cd user-fridge-notification-service/` 2. `sam build --use-container` 3. `sam local invoke HelloWorldFunction --event events/event.json` * response: ```{"statusCode": 200, "body": "{\"message\": \"hello world\"}"}``` @@ -90,23 +90,43 @@ Note: make sure your local dynamodb instance is running on docker. Follow instru #### Local Invoke -To test locally we use `sam local invoke` to mimick a cognito authorized request +To test locally we use `sam local invoke` to mimick a Firebase authorized request + +**Note:** You'll need to replace `` in the event JSON files with a valid Firebase ID token from your Firebase project 1. POST Example ```bash - sam local invoke UserFridgeNotificationsFunction --event events/post_notification.json --parameter-overrides ParameterKey=Environment,ParameterValue=local ParameterKey=Stage,ParameterValue=dev --docker-network cfm-network + sam local invoke UserFridgeNotificationsFunction --event events/post_notification.json --parameter-overrides ParameterKey=DeploymentTarget,ParameterValue=local ParameterKey=Environment,ParameterValue=dev ParameterKey=FirebaseProjectId,ParameterValue=your-firebase-project-id --docker-network cfm-network ``` -2. PUT Example +2. PATCH Example ```bash - sam local invoke UserFridgeNotificationsFunction --event events/put_notification.json --parameter-overrides ParameterKey=Environment,ParameterValue=local ParameterKey=Stage,ParameterValue=dev --docker-network cfm-network + sam local invoke UserFridgeNotificationsFunction --event events/patch_notification.json --parameter-overrides ParameterKey=DeploymentTarget,ParameterValue=local ParameterKey=Environment,ParameterValue=dev ParameterKey=FirebaseProjectId,ParameterValue=your-firebase-project-id --docker-network cfm-network ``` 3. GET Example ```bash - sam local invoke UserFridgeNotificationsFunction --event events/get_notification.json --parameter-overrides ParameterKey=Environment,ParameterValue=local ParameterKey=Stage,ParameterValue=dev --docker-network cfm-network + sam local invoke UserFridgeNotificationsFunction --event events/get_notification.json --parameter-overrides ParameterKey=DeploymentTarget,ParameterValue=local ParameterKey=Environment,ParameterValue=dev ParameterKey=FirebaseProjectId,ParameterValue=your-firebase-project-id --docker-network cfm-network ``` +#### Get All User Notifications + +To retrieve all notification preferences for a user across all fridges: + +URL format: `v1/users/{user_id}/fridge-notifications` + +**Local Invoke Example:** +```bash +sam local invoke GetAllUserFridgeNotificationsFunction --event events/get_all_user_notifications.json --parameter-overrides ParameterKey=DeploymentTarget,ParameterValue=local ParameterKey=Environment,ParameterValue=dev ParameterKey=FirebaseProjectId,ParameterValue=your-firebase-project-id --docker-network cfm-network +``` + +#### User Deletion Handler +Cleans up notifications when a user is deleted: + +```bash +Notification$ sam local invoke UserDeletionHandlerFunction --event events/user_deletion_event.json --parameter-overrides ParameterKey=DeploymentTarget,ParameterValue=local ParameterKey=Environment,ParameterValue=dev ParameterKey=FirebaseProjectId,ParameterValue=your-firebase-project-id --docker-network cfm-network +``` + --- ## Running Unit Tests @@ -127,13 +147,13 @@ pip install -e . 3. Install test dependencies: ```sh -pip install -r Notification/tests/requirements.txt +pip install -r user-fridge-notification-service/tests/requirements.txt ``` 4. Run tests (unittest discovery): ```sh -python -m unittest discover -s Notification/tests/unit -t . +python -m unittest discover -s user-fridge-notification-service/tests/unit -t . ``` Or run a single test file: @@ -148,27 +168,32 @@ deactivate ``` ## Deployment -1. Build: `sam build --use-container` -2. Deploy: `sam deploy --config-file samconfig.toml --config-env ` - * edit CFMHostedZoneId in samconfig.toml - -## APIs with Cognito ID Token - Dev Server Examples -Only authenticated users are able to get or edit their notification preferences - -### GET -``` -curl --location --request GET 'https://notifications-api-dev.communityfridgefinder.com/v1/users//notifications/' --header 'Authorization: ' -``` - -### POST/PUT -``` -curl --location --request 'https://notifications-api-dev.communityfridgefinder.com/v1/users//notifications/' ---header 'Authorization: ' ---header 'Content-Type: application/json' ---data '{ - "contact_types_status": {"sms": "stop"}, - "contact_types_preferences": {"sms": {"good": true, "dirty": true, "out_of_order": true, "not_at_location": true, "ghost": true, "food_level_0": false, "food_level_1": false, "food_level_2": true, "food_level_3": true, "cleaned": false}}, - "contact_info": {"sms": "+18577048438"} - }' -``` \ No newline at end of file +### Prerequisites +- Update `samconfig.toml` with your: + - `CFMHostedZoneId` (from Route53 console) + - `FirebaseProjectId` (your Firebase project ID) + +### Deploy Steps +1. Navigate to service directory: `cd user-fridge-notification-service/` +2. Build: `sam build --use-container` +3. Deploy to environment: + ```bash + # Deploy to dev + sam deploy --config-file samconfig.toml --config-env dev + + # Deploy to staging + sam deploy --config-file samconfig.toml --config-env staging + + # Deploy to prod + sam deploy --config-file samconfig.toml --config-env prod + ``` + +### Environment Variables +The following parameters are configured per environment: +- `DeploymentTarget`: `aws` (for deployed environments) or `local` (for local testing) +- `Environment`: `dev`, `staging`, or `prod` +- `CFMHostedZoneId`: Route53 Hosted Zone ID for custom domain +- `FirebaseProjectId`: Firebase Project ID for authentication + +See [FIREBASE_AUTH_SETUP.md](FIREBASE_AUTH_SETUP.md) for Firebase configuration details diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..ef27f92 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,6 @@ +# Keep the docs folder in git +* +!.gitignore +!index.html +!openapi.yaml +!README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..398c71c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# FridgeFinder Notifications API Documentation + +Interactive API documentation hosted via GitHub Pages. + +## View Documentation + +Visit: https://fridgefinder.github.io/CFM_Notification/ + +## Local Development + +To view the documentation locally: + +1. Install a local web server (e.g., Python's http.server) +2. Navigate to the docs directory +3. Run: `python3 -m http.server 8000` +4. Open: http://localhost:8000 + +## Updating Documentation + +1. Edit `docs/openapi.yaml` +2. Commit and push changes +3. GitHub Pages will automatically update + +## Files + +- `index.html` - Swagger UI interface +- `openapi.yaml` - OpenAPI 3.0 specification diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..c9f44ed --- /dev/null +++ b/docs/index.html @@ -0,0 +1,58 @@ + + + + + + + FridgeFinder Notifications API Documentation + + + + +
+ + + + + diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..c569ad4 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,637 @@ +openapi: 3.0.3 +info: + title: FridgeFinder User-Fridge-Notifications API + description: | + APIs for managing user-fridge-notification preferences. + + ## Authentication + All endpoints require Firebase JWT authentication. + Include the JWT token in the `Authorization` header as `Bearer `. + + version: 1.0.0 + contact: + name: Fridge Finder + url: https://fridgefinder.app + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://notifications-api-dev.communityfridgefinder.com + description: Development environment + - url: https://notifications-api-staging.communityfridgefinder.com + description: Staging environment + - url: https://notifications-api-prod.communityfridgefinder.com + description: Production environment + - url: http://localhost:3000 + description: Local development + +security: + - FirebaseAuth: [] + +tags: + - name: Health + description: Health check endpoints + - name: Notifications + description: User fridge notification preferences + +paths: + /hello: + get: + tags: + - Health + summary: Health check endpoint + description: Simple health check endpoint to verify API is running + operationId: helloWorld + security: [] # No authentication required + responses: + '200': + description: Successful health check + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "hello world" + + /v1/users/{user_id}/fridge-notifications: + get: + tags: + - Notifications + summary: Get all notification preferences for a user + description: Retrieve all fridge notification preferences for the authenticated user + operationId: getAllUserFridgeNotifications + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '200': + description: Successfully retrieved notification preferences + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserFridgeNotification' + count: + type: integer + description: Number of notification preferences returned + example: 2 + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + + /v1/users/{user_id}/fridge-notifications/{fridge_id}: + get: + tags: + - Notifications + summary: Get notification preferences for a specific fridge + description: Retrieve notification preferences for a specific fridge and user combination + operationId: getUserFridgeNotification + parameters: + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/FridgeId' + responses: + '200': + description: Successfully retrieved notification preference + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/UserFridgeNotification' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + post: + tags: + - Notifications + summary: Create notification preferences for a fridge + description: Create new notification preferences for a specific fridge + operationId: postUserFridgeNotification + parameters: + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/FridgeId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreferencesInput' + examples: + emailOnly: + summary: Email notifications only + value: + contactTypePreferences: + email: + good: true + dirty: true + outOfOrder: true + notAtLocation: false + ghost: false + foodLevel0: true + foodLevel1: false + foodLevel2: false + foodLevel3: false + deviceOnly: + summary: Device push notifications only + value: + contactTypePreferences: + device: + good: false + dirty: true + outOfOrder: true + notAtLocation: true + ghost: true + foodLevel0: true + foodLevel1: true + foodLevel2: false + foodLevel3: false + both: + summary: Both email and device notifications + value: + contactTypePreferences: + email: + good: true + dirty: true + outOfOrder: true + notAtLocation: true + ghost: true + foodLevel0: true + foodLevel1: false + foodLevel2: false + foodLevel3: false + device: + good: false + dirty: true + outOfOrder: true + notAtLocation: true + ghost: false + foodLevel0: true + foodLevel1: true + foodLevel2: false + foodLevel3: false + responses: + '201': + description: Successfully created notification preference + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/UserFridgeNotification' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + patch: + tags: + - Notifications + summary: Partially update notification preferences for a fridge + description: | + Partially update existing notification preferences for a specific fridge. + Only the fields provided in the request will be updated. Fields not included + will retain their current values. This allows for granular updates without + needing to send the complete preference object. + + Examples: + - Update only email preferences while leaving device preferences unchanged + - Update only specific fields within email preferences (e.g., just 'dirty') + - Add a new contact type (e.g., device) without affecting existing types + - Remove a contact type by setting it to null + operationId: patchUserFridgeNotification + parameters: + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/FridgeId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreferencesInput' + examples: + partialFieldUpdate: + summary: Update only specific fields within a contact type + description: Only updates the 'dirty' field in email preferences, all other fields remain unchanged + value: + contactTypePreferences: + email: + dirty: false + updateEmailOnly: + summary: Update only email preferences + description: Updates all email preference fields, device preferences remain unchanged + value: + contactTypePreferences: + email: + good: false + dirty: true + outOfOrder: true + notAtLocation: true + ghost: false + foodLevel0: true + foodLevel1: true + foodLevel2: false + foodLevel3: false + addDevice: + summary: Add device preferences + description: Adds device preferences while keeping existing email preferences unchanged + value: + contactTypePreferences: + device: + good: true + dirty: true + outOfOrder: true + notAtLocation: true + ghost: true + foodLevel0: true + foodLevel1: true + foodLevel2: true + foodLevel3: true + updateBothContactTypes: + summary: Update both email and device preferences + description: Updates specific fields in both email and device contact types simultaneously + value: + contactTypePreferences: + email: + dirty: false + ghost: false + device: + good: true + foodLevel0: false + removeContactType: + summary: Remove a contact type + description: Sets email to null to remove email notifications + value: + contactTypePreferences: + email: null + responses: + '200': + description: Successfully updated notification preference + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/UserFridgeNotification' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Notifications + summary: Delete notification preferences for a fridge + description: Remove notification preferences for a specific fridge and user combination + operationId: deleteUserFridgeNotification + parameters: + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/FridgeId' + responses: + '204': + description: Successfully deleted notification preference + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + +components: + securitySchemes: + FirebaseAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + Firebase JWT token. Include the token in the Authorization header as: `Bearer ` + + The token must be issued by Firebase Authentication and include: + - iss: https://securetoken.google.com/{projectId} + - aud: {projectId} + - sub: User's Firebase UID + + parameters: + UserId: + name: user_id + in: path + required: true + description: Firebase User ID (UID) - must match the authenticated user's ID + schema: + type: string + minLength: 3 + maxLength: 100 + example: "abc123xyz456def789" + + FridgeId: + name: fridge_id + in: path + required: true + description: Unique identifier for the community fridge + schema: + type: string + minLength: 3 + maxLength: 100 + example: "fridge_123abc" + + headers: + X-Request-ID: + description: Unique request identifier for tracing + schema: + type: string + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + schemas: + UserFridgeNotification: + type: object + required: + - userId + - fridgeId + - contactTypePreferences + - createdAt + - updatedAt + properties: + userId: + type: string + minLength: 3 + maxLength: 100 + description: Firebase User ID + example: "abc123xyz456def789" + fridgeId: + type: string + minLength: 3 + maxLength: 100 + description: Unique identifier for the community fridge + example: "fridge_123abc" + contactTypePreferences: + $ref: '#/components/schemas/ContactTypePreferences' + createdAt: + type: string + format: date-time + description: ISO 8601 timestamp with milliseconds when the preference was created + example: "2025-12-13T10:30:45.123Z" + updatedAt: + type: string + format: date-time + description: ISO 8601 timestamp with milliseconds when the preference was last updated + example: "2025-12-13T15:20:30.456Z" + + NotificationPreferencesInput: + type: object + required: + - contactTypePreferences + properties: + contactTypePreferences: + $ref: '#/components/schemas/ContactTypePreferences' + + ContactTypePreferences: + type: object + description: Notification preferences by contact method (email and/or device push) + properties: + email: + $ref: '#/components/schemas/FridgePreferences' + device: + $ref: '#/components/schemas/FridgePreferences' + minProperties: 1 + example: + email: + good: true + dirty: true + outOfOrder: true + notAtLocation: false + ghost: false + foodLevel0: true + foodLevel1: false + foodLevel2: false + foodLevel3: false + + FridgePreferences: + type: object + required: + - good + - foodLevel0 + description: | + Notification preferences for different fridge states and food levels. + + Fridge States: + - good: Fridge is operational and in good condition + - dirty: Fridge needs cleaning + - outOfOrder: Fridge is broken or malfunctioning + - notAtLocation: Fridge has been moved or is missing + - ghost: Fridge doesn't exist at reported location + + Food Levels: + - foodLevel0: Empty (0%) + - foodLevel1: Low (1-33%) + - foodLevel2: Medium (34-66%) + - foodLevel3: Full (67-100%) + properties: + good: + type: boolean + description: Notify when fridge is reported as good/operational + dirty: + type: boolean + default: true + description: Notify when fridge is reported as dirty + outOfOrder: + type: boolean + default: true + description: Notify when fridge is reported as out of order + notAtLocation: + type: boolean + default: true + description: Notify when fridge is reported as not at its location + ghost: + type: boolean + default: true + description: Notify when fridge is reported as non-existent (ghost) + foodLevel0: + type: boolean + description: Notify when fridge is empty (0%) + foodLevel1: + type: boolean + description: Notify when fridge has low food (1-33%) + foodLevel2: + type: boolean + description: Notify when fridge has medium food (34-66%) + foodLevel3: + type: boolean + description: Notify when fridge is full (67-100%) + example: + good: true + dirty: true + outOfOrder: true + notAtLocation: false + ghost: false + foodLevel0: true + foodLevel1: false + foodLevel2: false + foodLevel3: false + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: object + required: + - code + - message + properties: + code: + type: string + enum: + - UNAUTHORIZED + - FORBIDDEN + - MISSING_BODY + - INVALID_JSON + - VALIDATION_ERROR + - IMMUTABLE_FIELD + - MISSING_REQUIRED_FIELD + - NOT_FOUND + - ALREADY_EXISTS + - INTERNAL_SERVER_ERROR + - DATABASE_ERROR + - INVALID_HTTP_METHOD + description: Machine-readable error code + message: + type: string + description: Human-readable error message + + responses: + BadRequest: + description: Bad request - invalid input + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + missingBody: + summary: Missing request body + value: + error: + code: "MISSING_BODY" + message: "Request body is required" + invalidJson: + summary: Invalid JSON + value: + error: + code: "INVALID_JSON" + message: "Invalid JSON in request body" + validationError: + summary: Validation error + value: + error: + code: "VALIDATION_ERROR" + message: "Input validation failed" + + Unauthorized: + description: Authentication failed - invalid or missing JWT token + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: "UNAUTHORIZED" + message: "Authentication failed: No sub found in JWT" + + Forbidden: + description: Forbidden - user can only access their own data + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: "FORBIDDEN" + message: "Unauthorized: User can only access their own data" + + NotFound: + description: Resource not found + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: "NOT_FOUND" + message: "User Fridge Notification preference not found" + + Conflict: + description: Resource already exists + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: "ALREADY_EXISTS" + message: "Notification preference already exists for this user and fridge" + + InternalServerError: + description: Internal server error + headers: + X-Request-ID: + $ref: '#/components/headers/X-Request-ID' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: "INTERNAL_SERVER_ERROR" + message: "An unexpected error occurred" diff --git a/local_schema/notifications.json b/local_schema/notifications.json index df8b80e..3b2dbf7 100644 --- a/local_schema/notifications.json +++ b/local_schema/notifications.json @@ -2,21 +2,21 @@ "TableName": "user_fridge_notifications_dev", "AttributeDefinitions": [ { - "AttributeName": "user_id", + "AttributeName": "userId", "AttributeType": "S" }, { - "AttributeName": "fridge_id", + "AttributeName": "fridgeId", "AttributeType": "S" } ], "KeySchema": [ { - "AttributeName": "user_id", + "AttributeName": "userId", "KeyType": "HASH" }, { - "AttributeName": "fridge_id", + "AttributeName": "fridgeId", "KeyType": "RANGE" } ], diff --git a/Notification/.gitignore b/user-fridge-notification-service/.gitignore similarity index 100% rename from Notification/.gitignore rename to user-fridge-notification-service/.gitignore diff --git a/Notification/README.md b/user-fridge-notification-service/README.md similarity index 100% rename from Notification/README.md rename to user-fridge-notification-service/README.md diff --git a/Notification/__init__.py b/user-fridge-notification-service/__init__.py similarity index 100% rename from Notification/__init__.py rename to user-fridge-notification-service/__init__.py diff --git a/Notification/dependencies/__init__.py b/user-fridge-notification-service/dependencies/__init__.py similarity index 100% rename from Notification/dependencies/__init__.py rename to user-fridge-notification-service/dependencies/__init__.py diff --git a/Notification/dependencies/python/__init__.py b/user-fridge-notification-service/dependencies/python/__init__.py similarity index 100% rename from Notification/dependencies/python/__init__.py rename to user-fridge-notification-service/dependencies/python/__init__.py diff --git a/Notification/dependencies/python/requirements.txt b/user-fridge-notification-service/dependencies/python/requirements.txt similarity index 72% rename from Notification/dependencies/python/requirements.txt rename to user-fridge-notification-service/dependencies/python/requirements.txt index a1ccc9c..9194d8a 100644 --- a/Notification/dependencies/python/requirements.txt +++ b/user-fridge-notification-service/dependencies/python/requirements.txt @@ -1,3 +1,4 @@ pydantic>=2.0 email-validator>=2.1.0 phonenumbers>=8.12.0 +firebase-admin==6.5.0 diff --git a/user-fridge-notification-service/dependencies/python/response_utils.py b/user-fridge-notification-service/dependencies/python/response_utils.py new file mode 100644 index 0000000..64016f2 --- /dev/null +++ b/user-fridge-notification-service/dependencies/python/response_utils.py @@ -0,0 +1,128 @@ +"""Utility functions for creating standardized API responses""" +import json +import logging +from enum import Enum +from typing import Optional + +logger = logging.getLogger() + + +class ErrorCode(str, Enum): + """Enum for standardized error codes""" + # Authentication/Authorization errors + UNAUTHORIZED = "UNAUTHORIZED" + FORBIDDEN = "FORBIDDEN" + + # Validation errors + MISSING_BODY = "MISSING_BODY" + INVALID_JSON = "INVALID_JSON" + VALIDATION_ERROR = "VALIDATION_ERROR" + IMMUTABLE_FIELD = "IMMUTABLE_FIELD" + MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD" + + # Resource errors + NOT_FOUND = "NOT_FOUND" + ALREADY_EXISTS = "ALREADY_EXISTS" + + # Server errors + INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" + DATABASE_ERROR = "DATABASE_ERROR" + + # Method errors + INVALID_HTTP_METHOD = "INVALID_HTTP_METHOD" + + +class ApiResponse: + """Standardized API response wrapper""" + + def __init__(self, status_code: int, body: dict, request_id: str = None): + self.status_code = status_code + self.body = body + self.request_id = request_id + + def api_format(self) -> dict: + """Format response for API Gateway""" + headers = { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + } + + # Add request ID to response headers for client-side tracing + if self.request_id: + headers["X-Request-ID"] = self.request_id + + return { + "statusCode": self.status_code, + "headers": headers, + "body": json.dumps(self.body) + } + + +def error_response( + status_code: int, + message: str, + code: ErrorCode, + request_id: Optional[str] = None, + log_level: Optional[str] = None, + extra: Optional[dict] = None +) -> dict: + """ + Create a standardized error response with optional logging. + + Args: + status_code: HTTP status code + message: Human-readable error message + code: ErrorCode enum value + request_id: Optional request ID for tracing + log_level: Optional log level ('info', 'warning', 'error'). If provided, will log with structured context. + extra: Optional dict of additional context fields for structured logging (e.g., {"user_id": "123", "operation": "get"}) + + Returns: + Formatted API Gateway response dict + + Examples: + # Simple error without logging + return error_response(404, "Not found", ErrorCode.NOT_FOUND) + + # Error with automatic logging + return error_response( + 500, "Missing fridge_id", ErrorCode.INTERNAL_SERVER_ERROR, + request_id=request_id, + log_level="error", + extra={"user_id": user_id, "fridge_id": fridge_id} + ) + """ + # Optional structured logging + if log_level: + log_func = getattr(logger, log_level.lower(), logger.info) + log_context = extra.copy() if extra else {} + log_context['error_code'] = code.value + log_context['status_code'] = status_code + if request_id: + log_context['request_id'] = request_id + log_func(message, extra=log_context) + + return ApiResponse( + status_code=status_code, + body={"error": {"message": message, "code": code.value}}, + request_id=request_id + ).api_format() + + +def success_response(status_code: int, data: dict, request_id: str = None) -> dict: + """ + Create a standardized success response. + + Args: + status_code: HTTP status code + data: Response data + request_id: Optional request ID for tracing + + Returns: + Formatted API Gateway response dict + """ + return ApiResponse( + status_code=status_code, + body=data, + request_id=request_id + ).api_format() diff --git a/user-fridge-notification-service/dependencies/python/user_fridge_notifications_model.py b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_model.py new file mode 100644 index 0000000..4911b91 --- /dev/null +++ b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_model.py @@ -0,0 +1,89 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import ClassVar +from typing import Optional +from datetime import datetime, timezone +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def get_utc_timestamp() -> str: + """Generate ISO 8601 timestamp with Z suffix and milliseconds for frontend compatibility""" + return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' + +class FridgePreferencesModel(BaseModel): + model_config = ConfigDict(extra="forbid") # Forbid extra fields + good: bool + dirty: bool = True + outOfOrder: bool = True + notAtLocation: bool = True + ghost: bool = True + foodLevel0: bool + foodLevel1: bool + foodLevel2: bool + foodLevel3: bool + +class ContactTypePreferencesModel(BaseModel): + model_config = ConfigDict(extra="forbid") # Forbid extra fields + email: Optional[FridgePreferencesModel] = None + device: Optional[FridgePreferencesModel] = None + +class UserFridgeNotificationModel(BaseModel): + #NOTE: MIN_ID_LENGTH and MAX_ID_LENGTH is used in a different service as well, should maybe set as an environment field on aws + MIN_ID_LENGTH: ClassVar[int] = 3 + MAX_ID_LENGTH: ClassVar[int] = 100 + userId: str = Field(..., min_length = MIN_ID_LENGTH, max_length = MAX_ID_LENGTH) + fridgeId: str = Field(..., min_length = MIN_ID_LENGTH, max_length = MAX_ID_LENGTH) + contactTypePreferences: ContactTypePreferencesModel + createdAt: str = Field(default_factory=get_utc_timestamp) + updatedAt: str = Field(default_factory=get_utc_timestamp) + #last_notified? + #last_emailed? + + def update_preferences(self, contactTypePreferences: dict) -> None: + """ + Update contact type preferences and refresh the updatedAt timestamp. + + Args: + contactTypePreferences: New preferences dict to validate and set + """ + # Pydantic will validate the input when we create the model + self.contactTypePreferences = ContactTypePreferencesModel(**contactTypePreferences) + self.updatedAt = get_utc_timestamp() + + def patch_preferences(self, partialContactTypePreferences: dict) -> None: + """ + Partially update contact type preferences, only modifying fields that are provided. + Preserves existing values for fields not included in the partial update. + + Args: + partialContactTypePreferences: Partial preferences dict with only the fields to update + + Examples: + # Update only email preferences, leaving device unchanged + model.patch_preferences({"email": {"dirty": True, "good": False, ...}}) + + # Update only the 'dirty' field within email preferences + model.patch_preferences({"email": {"dirty": True}}) + """ + # Start with current preferences as dict + current_prefs = self.contactTypePreferences.model_dump() + + # Iterate over provided contact types (e.g., 'email', 'device') + for contact_type, partial_prefs in partialContactTypePreferences.items(): + if partial_prefs is None: + current_prefs[contact_type] = None + continue + + if current_prefs.get(contact_type) is not None: + # Contact type exists, merge the partial preferences + for field, value in partial_prefs.items(): + current_prefs[contact_type][field] = value + else: + # Contact type doesn't exist yet, validate and set it + # Pydantic will validate this has all required fields + current_prefs[contact_type] = partial_prefs + + # Validate the merged result and update + self.contactTypePreferences = ContactTypePreferencesModel(**current_prefs) + self.updatedAt = get_utc_timestamp() diff --git a/user-fridge-notification-service/dependencies/python/user_fridge_notifications_repository.py b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_repository.py new file mode 100644 index 0000000..88bf581 --- /dev/null +++ b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_repository.py @@ -0,0 +1,176 @@ +from typing import Optional, List, Dict, Any +import logging +import os + +try: + from user_fridge_notifications_model import UserFridgeNotificationModel +except ModuleNotFoundError: + from Notification.dependencies.python.user_fridge_notifications_model import UserFridgeNotificationModel + +from boto3.dynamodb.types import TypeSerializer, TypeDeserializer + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +def dict_to_dynamodb(data: dict) -> dict: + """Convert Python dict to DynamoDB format""" + serializer = TypeSerializer() + return {k: serializer.serialize(v) for k, v in data.items()} + + +def dynamodb_to_dict(ddb_item: dict) -> dict: + """Convert DynamoDB item to Python dict""" + deserializer = TypeDeserializer() + return {k: deserializer.deserialize(v) for k, v in ddb_item.items()} + + +class UserFridgeNotificationRepository: + """Repository for DynamoDB operations on user fridge notifications. + + Methods return plain Python dicts (deserialized from DynamoDB wire format) + instead of Pydantic model instances. + """ + + def __init__(self, db_client: Any, table_name: str): + self.db_client = db_client + self.table_name = table_name + + def get(self, user_id: str, fridge_id: str) -> Optional[Dict[str, Any]]: + """ + Fetch a user fridge notification by user_id and fridge_id. + + Args: + user_id: The user ID + fridge_id: The fridge ID + + Returns: + Dict[str, Any] if found, None otherwise + + Raises: + ClientError: If DynamoDB operation fails + """ + key = dict_to_dynamodb({"userId": user_id, "fridgeId": fridge_id}) + + result = self.db_client.get_item(TableName=self.table_name, Key=key) + + if "Item" not in result: + return None + + return dynamodb_to_dict(result["Item"]) + + def create(self, model: UserFridgeNotificationModel) -> None: + """ + Create a new user fridge notification. + + Args: + model: The UserFridgeNotificationModel to create + + Raises: + ClientError: If DynamoDB operation fails + - ConditionalCheckFailedException if item already exists + - Other ClientErrors for DynamoDB failures + """ + dict_format = model.model_dump(mode="json") + serialized_data = dict_to_dynamodb(dict_format) + + self.db_client.put_item( + TableName=self.table_name, + Item=serialized_data, + ConditionExpression="attribute_not_exists(userId) AND attribute_not_exists(fridgeId)" + ) + + def update(self, ufn_model: UserFridgeNotificationModel) -> None: + """ + Update an existing user fridge notification. + + Args: + model: The UserFridgeNotificationModel with updated values + + Raises: + ClientError: If DynamoDB operation fails + - ConditionalCheckFailedException if item doesn't exist + - Other ClientErrors for DynamoDB failures + """ + dict_format = ufn_model.model_dump(mode="json") + serialized_data = dict_to_dynamodb(dict_format) + + self.db_client.put_item( + TableName=self.table_name, + Item=serialized_data, + ConditionExpression="attribute_exists(userId) AND attribute_exists(fridgeId)" + ) + + def delete(self, user_id: str, fridge_id: str) -> bool: + """ + Delete a user fridge notification. + + Args: + user_id: The user ID + fridge_id: The fridge ID + + Returns: + True if deleted, False if item didn't exist + + Raises: + ClientError: If DynamoDB operation fails + """ + key = dict_to_dynamodb({"userId": user_id, "fridgeId": fridge_id}) + + response = self.db_client.delete_item( + TableName=self.table_name, + Key=key, + ReturnValues="ALL_OLD" + ) + # If Attributes is in response, item existed and was deleted + return "Attributes" in response + + def list_by_user(self, user_id: str) -> List[Dict[str, Any]]: + """ + Get all notification preferences for a user. + + Args: + user_id: The user ID + + Returns: + List of Dict[str, Any] + + Raises: + ClientError: If DynamoDB operation fails + """ + response = self.db_client.query( + TableName=self.table_name, + KeyConditionExpression="userId = :userId", + ExpressionAttributeValues={ + ":userId": {"S": user_id} + } + ) + + items = response.get("Items", []) + return [dynamodb_to_dict(item) for item in items] + + def list_by_fridge(self, fridge_id: str) -> List[Dict[str, Any]]: + """ + Get all users subscribed to notifications for a specific fridge. + Uses the FridgeIndex GSI. + + Args: + fridge_id: The fridge ID + + Returns: + List of Dict[str, Any] + + Raises: + ClientError: If DynamoDB operation fails + """ + response = self.db_client.query( + TableName=self.table_name, + IndexName="FridgeIndex", + KeyConditionExpression="fridgeId = :fridgeId", + ExpressionAttributeValues={ + ":fridgeId": {"S": fridge_id} + } + ) + + items = response.get("Items", []) + return [dynamodb_to_dict(item) for item in items] diff --git a/user-fridge-notification-service/dependencies/python/user_fridge_notifications_service.py b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_service.py new file mode 100644 index 0000000..ad0cb93 --- /dev/null +++ b/user-fridge-notification-service/dependencies/python/user_fridge_notifications_service.py @@ -0,0 +1,125 @@ +from xml.parsers.expat import model +from botocore.exceptions import ClientError +from pydantic import ValidationError +import logging + +try: + # Preferred: relative import when running inside SAM/local container + from user_fridge_notifications_model import UserFridgeNotificationModel + from user_fridge_notifications_repository import UserFridgeNotificationRepository + from response_utils import error_response, success_response, ErrorCode +except ModuleNotFoundError: + # Fallback: absolute import when package is installed for tests + from Notification.dependencies.python.user_fridge_notifications_model import UserFridgeNotificationModel + from Notification.dependencies.python.user_fridge_notifications_repository import UserFridgeNotificationRepository + from Notification.dependencies.python.response_utils import error_response, success_response, ErrorCode + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +class UserFridgeNotificationService: + """Service layer for user fridge notification business logic""" + + def __init__(self, repository: UserFridgeNotificationRepository): + self.repository = repository + + def get_user_fridge_notification(self, userId: str, fridgeId: str, request_id: str = None) -> dict: + """Get user fridge notification preferences (returns plain dict).""" + ufn_dict = self.repository.get(userId, fridgeId) + + if ufn_dict is None: + return error_response(404, "Item not found", ErrorCode.NOT_FOUND, request_id=request_id) + return success_response(200, ufn_dict, request_id=request_id) + + def post_user_fridge_notification(self, user_notification_model: UserFridgeNotificationModel, request_id: str = None) -> dict: + """Create new user fridge notification preferences""" + try: + self.repository.create(user_notification_model) + logger.info( + "User fridge notification created successfully", + extra={ + "request_id": request_id, + "userId": user_notification_model.userId, + "fridgeId": user_notification_model.fridgeId, + "operation": "create_user_fridge_notification", + } + ) + return success_response(201, user_notification_model.model_dump(mode="json"), request_id=request_id) + except ClientError as e: + if e.response['Error'].get('Code') == "ConditionalCheckFailedException": + error_message = f"UserFridgeNotification with userId: {user_notification_model.userId}, and fridgeId: {user_notification_model.fridgeId} already exists" + return error_response(409, error_message, ErrorCode.ALREADY_EXISTS, request_id=request_id) + raise + + def patch_user_fridge_notification(self, userId: str, fridgeId: str, contactTypePreferences: dict, request_id: str = None) -> dict: + """ + Partially update existing user fridge notification preferences. + + Args: + userId: The user ID + fridgeId: The fridge ID + contactTypePreferences: New contact type preferences to update + request_id: The request ID for tracing + + Returns: + API Gateway formatted response dict + """ + try: + # Fetch existing item (dict) + ufn_dict = self.repository.get(userId, fridgeId) + + if ufn_dict is None: + return error_response( + 404, "User Fridge Notification not found", + ErrorCode.NOT_FOUND, + request_id=request_id + ) + + # Convert to model to leverage validation and helper, update preferences + ufn_model = UserFridgeNotificationModel(**ufn_dict) + ufn_model.patch_preferences(contactTypePreferences) + + # Save updated model + self.repository.update(ufn_model) + return success_response(200, ufn_model.model_dump(mode="json"), request_id=request_id) + except ValidationError as ve: + # Pydantic validation error when validating contactTypePreferences + return error_response(400, str(ve), ErrorCode.VALIDATION_ERROR, request_id=request_id) + except ClientError as e: + if e.response['Error'].get('Code') == "ConditionalCheckFailedException": + # Super Duper Unlikely: Item was deleted between get and update + return error_response(404, "User Fridge Notification not found", ErrorCode.NOT_FOUND, request_id=request_id) + raise + + def get_all_user_notifications(self, userId: str, request_id: str = None) -> dict: + """ + Get all notification preferences for a user across all fridges. + + Args: + userId: The user ID + request_id: The request ID for tracing + + Returns: + API Gateway formatted response dict with list of notifications + """ + notifications = self.repository.list_by_user(userId) + return success_response(200, {"notifications": notifications, "count": len(notifications)}, request_id=request_id) + + def delete_user_fridge_notification(self, userId: str, fridgeId: str, request_id: str = None) -> dict: + """ + Delete user fridge notification preferences. + + Args: + userId: The user ID + fridgeId: The fridge ID + request_id: The request ID for tracing + + Returns: + API Gateway formatted response dict + """ + deleted = self.repository.delete(userId, fridgeId) + + if not deleted: + return error_response(404, "User Fridge Notification not found", ErrorCode.NOT_FOUND, request_id=request_id) + return success_response(204, None, request_id=request_id) + diff --git a/Notification/events/event.json b/user-fridge-notification-service/events/event.json similarity index 100% rename from Notification/events/event.json rename to user-fridge-notification-service/events/event.json diff --git a/user-fridge-notification-service/events/fridge_report_stream.json b/user-fridge-notification-service/events/fridge_report_stream.json new file mode 100644 index 0000000..063b1f2 --- /dev/null +++ b/user-fridge-notification-service/events/fridge_report_stream.json @@ -0,0 +1,120 @@ +{ + "Records": [ + { + "eventID": "1", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "us-east-1", + "dynamodb": { + "ApproximateCreationDateTime": 1638360000, + "Keys": { + "fridge_id": { + "S": "fridge-123" + }, + "report_id": { + "S": "report-456" + } + }, + "NewImage": { + "fridge_id": { + "S": "fridge-123" + }, + "report_id": { + "S": "report-456" + }, + "status": { + "S": "submitted" + }, + "reported_by": { + "S": "user-789" + }, + "issue_type": { + "S": "maintenance" + }, + "description": { + "S": "Fridge door not closing properly" + }, + "timestamp": { + "N": "1638360000" + } + }, + "SequenceNumber": "111", + "SizeBytes": 26, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn:aws:dynamodb:us-east-1:123456789012:table/FridgeReports/stream/2021-12-01T00:00:00.000" + }, + { + "eventID": "2", + "eventName": "MODIFY", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "us-east-1", + "dynamodb": { + "ApproximateCreationDateTime": 1638360060, + "Keys": { + "fridge_id": { + "S": "fridge-123" + }, + "report_id": { + "S": "report-456" + } + }, + "NewImage": { + "fridge_id": { + "S": "fridge-123" + }, + "report_id": { + "S": "report-456" + }, + "status": { + "S": "in_progress" + }, + "reported_by": { + "S": "user-789" + }, + "issue_type": { + "S": "maintenance" + }, + "description": { + "S": "Fridge door not closing properly" + }, + "timestamp": { + "N": "1638360000" + }, + "updated_at": { + "N": "1638360060" + } + }, + "OldImage": { + "fridge_id": { + "S": "fridge-123" + }, + "report_id": { + "S": "report-456" + }, + "status": { + "S": "submitted" + }, + "reported_by": { + "S": "user-789" + }, + "issue_type": { + "S": "maintenance" + }, + "description": { + "S": "Fridge door not closing properly" + }, + "timestamp": { + "N": "1638360000" + } + }, + "SequenceNumber": "222", + "SizeBytes": 59, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn:aws:dynamodb:us-east-1:123456789012:table/FridgeReports/stream/2021-12-01T00:00:00.000" + } + ] +} diff --git a/user-fridge-notification-service/events/get_all_user_notifications.json b/user-fridge-notification-service/events/get_all_user_notifications.json new file mode 100644 index 0000000..3e0bad9 --- /dev/null +++ b/user-fridge-notification-service/events/get_all_user_notifications.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "routeKey": "GET /v1/users/{user_id}/fridge-notifications", + "rawPath": "/v1/users/user_1/fridge-notifications", + "headers": { + "authorization": "Bearer " + }, + "pathParameters": { + "user_id": "user_1" + }, + "requestContext": { + "http": { + "method": "GET", + "path": "/v1/users/user_1/fridge-notifications", + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.1.2" + }, + "authorizer": { + "jwt": { + "claims": { + "sub": "user_1", + "email": "test@example.com", + "email_verified": "true" + } + } + } + }, + "body": null, + "isBase64Encoded": false +} diff --git a/user-fridge-notification-service/events/get_notification.json b/user-fridge-notification-service/events/get_notification.json new file mode 100644 index 0000000..215b121 --- /dev/null +++ b/user-fridge-notification-service/events/get_notification.json @@ -0,0 +1,31 @@ +{ + "version": "2.0", + "routeKey": "GET /v1/users/{user_id}/fridge-notifications/{fridge_id}", + "rawPath": "/v1/users/user_1/fridge-notifications/fridge_1", + "headers": { + "authorization": "Bearer " + }, + "pathParameters": { + "user_id": "user_1", + "fridge_id": "fridge_1" + }, + "requestContext": { + "http": { + "method": "GET", + "path": "/v1/users/user_1/fridge-notifications/fridge_1", + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.1.2" + }, + "authorizer": { + "jwt": { + "claims": { + "sub": "user_1", + "email": "test@example.com", + "email_verified": "true" + } + } + } + }, + "body": null, + "isBase64Encoded": false +} diff --git a/user-fridge-notification-service/events/patch_notification.json b/user-fridge-notification-service/events/patch_notification.json new file mode 100644 index 0000000..b339630 --- /dev/null +++ b/user-fridge-notification-service/events/patch_notification.json @@ -0,0 +1,32 @@ +{ + "version": "2.0", + "routeKey": "PUT /v1/users/{user_id}/fridge-notifications/{fridge_id}", + "rawPath": "/v1/users/user_1/fridge-notifications/fridge_1", + "headers": { + "content-type": "application/json", + "authorization": "Bearer " + }, + "pathParameters": { + "user_id": "user_1", + "fridge_id": "fridge_1" + }, + "requestContext": { + "http": { + "method": "PATCH", + "path": "/v1/users/user_1/fridge-notifications/fridge_1", + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.1.2" + }, + "authorizer": { + "jwt": { + "claims": { + "sub": "user_1", + "email": "test@example.com", + "email_verified": "true" + } + } + } + }, + "body": "{\n \"contactTypePreferences\": {\"email\": {\"good\": true, \"dirty\": true, \"outOfOrder\": true, \"notAtLocation\": true, \"ghost\": true, \"foodLevel0\": false, \"foodLevel1\": false, \"foodLevel2\": true, \"foodLevel3\": true}}\n}", + "isBase64Encoded": false +} diff --git a/user-fridge-notification-service/events/post_notification.json b/user-fridge-notification-service/events/post_notification.json new file mode 100644 index 0000000..509bdc7 --- /dev/null +++ b/user-fridge-notification-service/events/post_notification.json @@ -0,0 +1,32 @@ +{ + "version": "2.0", + "routeKey": "POST /v1/users/{user_id}/fridge-notifications/{fridge_id}", + "rawPath": "/v1/users/user_1/fridge-notifications/fridge_1", + "headers": { + "content-type": "application/json", + "authorization": "Bearer " + }, + "pathParameters": { + "user_id": "user_1", + "fridge_id": "fridge_1" + }, + "requestContext": { + "http": { + "method": "POST", + "path": "/v1/users/user_1/fridge-notifications/fridge_1", + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.1.2" + }, + "authorizer": { + "jwt": { + "claims": { + "sub": "user_1", + "email": "test@example.com", + "email_verified": "true" + } + } + } + }, + "body": "{\n \"contactTypePreferences\": {\"email\": {\"good\": true, \"dirty\": true, \"outOfOrder\": true, \"notAtLocation\": true, \"ghost\": true, \"foodLevel0\": false, \"foodLevel1\": false, \"foodLevel2\": true, \"foodLevel3\": true}}\n}", + "isBase64Encoded": false +} diff --git a/user-fridge-notification-service/events/user_deletion_event.json b/user-fridge-notification-service/events/user_deletion_event.json new file mode 100644 index 0000000..e032399 --- /dev/null +++ b/user-fridge-notification-service/events/user_deletion_event.json @@ -0,0 +1,15 @@ +{ + "version": "0", + "id": "12345678-1234-1234-1234-123456789012", + "detail-type": "User Deleted", + "source": "user-service", + "account": "123456789012", + "time": "2025-12-28T12:00:00Z", + "region": "us-east-1", + "resources": [], + "detail": { + "userId": "user_1", + "deletedAt": "1735387200", + "environment": "dev" + } +} diff --git a/Notification/functions/fridge_notifications/__init__.py b/user-fridge-notification-service/functions/fridge_notifications/__init__.py similarity index 100% rename from Notification/functions/fridge_notifications/__init__.py rename to user-fridge-notification-service/functions/fridge_notifications/__init__.py diff --git a/Notification/functions/fridge_notifications/getAllUserNotifications/__init__.py b/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/__init__.py similarity index 100% rename from Notification/functions/fridge_notifications/getAllUserNotifications/__init__.py rename to user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/__init__.py diff --git a/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/app.py b/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/app.py new file mode 100644 index 0000000..52b6bbd --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/app.py @@ -0,0 +1,122 @@ +import json +import os +import logging + +try: + from user_fridge_notifications_service import UserFridgeNotificationService + from user_fridge_notifications_repository import UserFridgeNotificationRepository + from response_utils import error_response, ErrorCode +except ModuleNotFoundError: + from Notification.dependencies.python.user_fridge_notifications_service import UserFridgeNotificationService + from Notification.dependencies.python.user_fridge_notifications_repository import UserFridgeNotificationRepository + from Notification.dependencies.python.response_utils import error_response, ErrorCode + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def get_ddb_connection() -> object: + """Create a DynamoDB client; uses LocalStack when env is 'local'.""" + import boto3 + env = os.environ["DEPLOYMENT_TARGET"] + if env == "aws": + return boto3.client("dynamodb") + else: + return boto3.client("dynamodb", endpoint_url="http://localstack:4566/") + +# Get Environment variables +table_name = os.environ["TABLE_NAME"] + +# Initialized only once per container +db_client = get_ddb_connection() +repository = UserFridgeNotificationRepository(db_client=db_client, table_name=table_name) +service = UserFridgeNotificationService(repository=repository) + +def get_authenticated_user_id(event): + """Extract userId (Firebase UUID) from JWT token claims.""" + return event.get('requestContext', {}).get('authorizer', {}).get('jwt', {}).get('claims', {}).get('sub') + +def validate_input(user_id: str, authenticated_user_id: str, request_id: str, path: str): + """Validate input parameters and return an error response if validation fails.""" + if not authenticated_user_id: + #Should never happen, if it gets here it's a configuration error + return error_response( + 500, "Authentication failed: No sub found in JWT", + ErrorCode.INTERNAL_SERVER_ERROR, + request_id=request_id, + log_level="error", + extra={'path': path} + ) + + if not user_id or not user_id.strip(): + return error_response( + 400, "Missing required path parameter: user_id", + ErrorCode.MISSING_REQUIRED_FIELD, + request_id=request_id, + log_level="error", + extra={"path": path} + ) + + if user_id != authenticated_user_id: + return error_response( + 403, "Unauthorized: User can only access their own data", + ErrorCode.FORBIDDEN, + request_id=request_id, + log_level="warning", + extra={ + "path_user_id": user_id, + "authenticated_user_id": authenticated_user_id, + "path": path + } + ) + return None + +def lambda_handler(event, context): + """ + Get all notification preferences for a user. + GET /v1/users/{user_id}/fridge-notifications + """ + request_id = context.aws_request_id if context else "unknown" + authenticated_user_id = get_authenticated_user_id(event) + user_id = event.get("pathParameters", {}).get("user_id") + path = event.get("rawPath", "unknown") + + logger.info( + "Request received", + extra={ + "request_id": request_id, + "http_method": "GET", + "user_id": user_id, + "authenticated_user_id": authenticated_user_id, + "path": path + } + ) + + invalid = validate_input(user_id=user_id, authenticated_user_id=authenticated_user_id, request_id=request_id, path=path) + if invalid: + return invalid + + try: + response = service.get_all_user_notifications(userId=user_id, request_id=request_id) + logger.info( + "Request completed", + extra={ + "request_id": request_id, + "http_method": "GET", + "user_id": user_id, + "path": path, + "status_code": response.get("statusCode") + } + ) + return response + except Exception as e: + logger.exception( + "Unhandled exception in lambda_handler", + extra={ + "request_id": request_id, + "http_method": "GET", + "user_id": user_id, + "path": path, + "error_type": type(e).__name__ + } + ) + return error_response(500, "Internal server error", ErrorCode.INTERNAL_SERVER_ERROR, request_id=request_id) \ No newline at end of file diff --git a/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/requirements.txt b/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/requirements.txt new file mode 100644 index 0000000..8767841 --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_notifications/getAllUserNotifications/requirements.txt @@ -0,0 +1 @@ +pydantic>=2.0 \ No newline at end of file diff --git a/Notification/functions/fridge_notifications/user/__init__.py b/user-fridge-notification-service/functions/fridge_notifications/user/__init__.py similarity index 100% rename from Notification/functions/fridge_notifications/user/__init__.py rename to user-fridge-notification-service/functions/fridge_notifications/user/__init__.py diff --git a/user-fridge-notification-service/functions/fridge_notifications/user/app.py b/user-fridge-notification-service/functions/fridge_notifications/user/app.py new file mode 100644 index 0000000..e2233ae --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_notifications/user/app.py @@ -0,0 +1,304 @@ +from pydantic import ValidationError +import os +import logging +import json +import requests + +try: + from user_fridge_notifications_model import UserFridgeNotificationModel + from user_fridge_notifications_service import UserFridgeNotificationService + from user_fridge_notifications_repository import UserFridgeNotificationRepository + from response_utils import error_response, ErrorCode +except ModuleNotFoundError: + # Fallback: absolute imports (works when package is installed / running tests) + from Notification.dependencies.python.user_fridge_notifications_model import UserFridgeNotificationModel + from Notification.dependencies.python.user_fridge_notifications_service import UserFridgeNotificationService + from Notification.dependencies.python.user_fridge_notifications_repository import UserFridgeNotificationRepository + from Notification.dependencies.python.response_utils import error_response, ErrorCode + +# Setup logger first +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def get_ddb_connection() -> object: + """Create a DynamoDB client; uses LocalStack when env is 'local'.""" + import boto3 + env = os.environ["DEPLOYMENT_TARGET"] + if env == "aws": + return boto3.client("dynamodb") + else: + return boto3.client("dynamodb", endpoint_url="http://localstack:4566/") + +# Get Environment variables +table_name = os.environ["TABLE_NAME"] +logger.info("TABLE_NAME=%s", table_name) + +# Initialized only once per container +db_client = get_ddb_connection() +repository = UserFridgeNotificationRepository(db_client=db_client, table_name=table_name) +service = UserFridgeNotificationService(repository=repository) + +def get_authenticated_user_id(event): + """ + Extract userId (Firebase UUID) from JWT token claims + API Gateway v2 (HTTP API) provides JWT claims in requestContext.authorizer.jwt.claims + Args: + event: API Gateway HTTP API event + Returns: + User ID from JWT 'sub' claim + """ + # For HTTP API with JWT authorizer, user ID is in the 'sub' claim + user_id = event.get('requestContext', {}).get('authorizer', {}).get('jwt', {}).get('claims', {}).get('sub') + return user_id + +def validate_request_parameters( + authenticated_user_id: str, + user_id: str, + fridge_id: str, + request_id: str, + path: str, + http_method: str +) -> dict: + """ + Validate required request parameters from API Gateway and enforce authorization. + + Args: + authenticated_user_id: User ID from JWT token + user_id: User ID from path parameters + fridge_id: Fridge ID from path parameters + request_id: Request ID for tracing + path: Request path for logging + http_method: HTTP method for logging + + Returns: + Error response dict if validation fails, None if all validations pass + """ + # Should never get here if API Gateway JWT authorizer is configured correctly + if not authenticated_user_id or not authenticated_user_id.strip(): + return error_response( + 500, "Authentication failed: No sub found in JWT", + ErrorCode.INTERNAL_SERVER_ERROR, + request_id=request_id, + log_level="error", + extra={'path': path} + ) + + # Validate required path parameters + if not fridge_id or not fridge_id.strip(): + return error_response( + 400, "Missing required path parameter: fridge_id", + ErrorCode.MISSING_REQUIRED_FIELD, + request_id=request_id, + log_level="error", + extra={"user_id": user_id, "path": path} + ) + + if not user_id or not user_id.strip(): + return error_response( + 400, "Missing required path parameter: user_id", + ErrorCode.MISSING_REQUIRED_FIELD, + request_id=request_id, + log_level="error", + extra={"fridge_id": fridge_id, "path": path} + ) + + # NOTE: don't need user_id in pathParameters but if we ever want to allow for ADMIN users to + # access other users' notifications we can keep it + # Enforce that the authenticated user can only access their own notifications + if user_id != authenticated_user_id: + return error_response( + 403, "Unauthorized: User can only access their own data", + ErrorCode.FORBIDDEN, + request_id=request_id, + log_level="warning", + extra={ + "path_user_id": user_id, + "authenticated_user_id": authenticated_user_id, + "http_method": http_method, + "path": path + } + ) + + return None # All validations passed + + +def handle_get_request(userId: str, fridgeId: str, request_id: str) -> dict: + """ + Handle GET request to retrieve user fridge notification preferences. + + Args: + userId: The authenticated user ID + fridgeId: The fridge ID + request_id: The request ID for tracing + + Returns: + API Gateway formatted response + """ + return service.get_user_fridge_notification(userId=userId, fridgeId=fridgeId, request_id=request_id) + + +def handle_post_request(event: dict, userId: str, fridgeId: str, request_id: str) -> dict: + """ + Handle POST request to create new user fridge notification preferences. + + Args: + event: Lambda event object + userId: The authenticated user ID + fridgeId: The fridge ID + request_id: The request ID for tracing + + Returns: + API Gateway formatted response + """ + body = event.get("body") + if not body: + return error_response(400, "Missing request body", ErrorCode.MISSING_BODY) + try: + body_dict = json.loads(body) + body_dict["userId"] = userId + body_dict["fridgeId"] = fridgeId + model = UserFridgeNotificationModel(**body_dict) + return service.post_user_fridge_notification(user_notification_model=model, request_id=request_id) + except json.JSONDecodeError: + return error_response(400, "Invalid JSON in request body", ErrorCode.INVALID_JSON, request_id=request_id) + except ValidationError as ve: + return error_response(400, str(ve), ErrorCode.VALIDATION_ERROR, request_id=request_id) + + +def handle_patch_request(event: dict, userId: str, fridgeId: str, request_id: str) -> dict: + """ + Handle PATCH request to partially update user fridge notification preferences. + Only contactTypePreferences can be updated; userId and fridgeId are immutable. + + Args: + event: Lambda event object + userId: The authenticated user ID (from path, immutable) + fridgeId: The fridge ID (from path, immutable) + request_id: The request ID for tracing + + Returns: + API Gateway formatted response + """ + body = event.get("body") + if not body: + return error_response(400, "Missing request body", ErrorCode.MISSING_BODY, request_id=request_id) + + try: + body_dict = json.loads(body) + # Only contactTypePreferences can be updated + contactTypePreferences = body_dict.get("contactTypePreferences") + if not contactTypePreferences: + return error_response(400, "contactTypePreferences is required", ErrorCode.MISSING_REQUIRED_FIELD, request_id=request_id) + + return service.patch_user_fridge_notification( + userId=userId, + fridgeId=fridgeId, + contactTypePreferences=contactTypePreferences, + request_id=request_id + ) + except json.JSONDecodeError: + return error_response(400, "Invalid JSON in request body", ErrorCode.INVALID_JSON, request_id=request_id) + except ValidationError as ve: + return error_response(400, str(ve), ErrorCode.VALIDATION_ERROR, request_id=request_id) + + +def handle_delete_request(userId: str, fridgeId: str, request_id: str) -> dict: + """ + Handle DELETE request to remove user fridge notification preferences. + + Args: + userId: The authenticated user ID + fridgeId: The fridge ID + request_id: The request ID for tracing + + Returns: + API Gateway formatted response + """ + return service.delete_user_fridge_notification(userId=userId, fridgeId=fridgeId, request_id=request_id) + + +def lambda_handler(event, context): + """ + Main Lambda handler that routes requests to appropriate method handlers. + Args: + event: Lambda event object from HTTP API Gateway + context: Lambda context object + Returns: + API Gateway formatted response + """ + request_id = context.aws_request_id if context else 'unknown' + authenticated_user_id = get_authenticated_user_id(event) + http_method = event.get("requestContext", {}).get("http", {}).get("method") + fridge_id = event.get("pathParameters", {}).get("fridge_id") + user_id = event.get("pathParameters", {}).get("user_id") + path = event.get("rawPath", "unknown") + # Log incoming request with structured fields for CloudWatch querying + logger.info( + "Request received", + extra={ + "request_id": request_id, + "http_method": http_method, + "user_id": user_id, + "fridge_id": fridge_id, + "authenticated_user_id": authenticated_user_id, + "path": path + } + ) + + # Validate request parameters and authorization + validation_error = validate_request_parameters( + authenticated_user_id, user_id, fridge_id, request_id, path, http_method + ) + if validation_error: + return validation_error + + # Route to appropriate handler based on HTTP method + try: + if http_method == "GET": + response = handle_get_request(userId=user_id, fridgeId=fridge_id, request_id=request_id) + elif http_method == "POST": + response = handle_post_request(event=event, userId=user_id, fridgeId=fridge_id, request_id=request_id) + elif http_method == "PATCH": + response = handle_patch_request(event=event, userId=user_id, fridgeId=fridge_id, request_id=request_id) + elif http_method == "DELETE": + response = handle_delete_request(userId=user_id, fridgeId=fridge_id, request_id=request_id) + else: + # Should never get here - indicates a configuration error + return error_response( + status_code=500, + message="Invalid HTTP method", + code=ErrorCode.INTERNAL_SERVER_ERROR, + request_id=request_id, + log_level="error", + extra={ + "http_method": http_method, + "path": path + } + ) + + # Log successful response + logger.info( + "Request completed", + extra={ + "request_id": request_id, + "http_method": http_method, + "user_id": user_id, + "fridge_id": fridge_id, + "path": path, + "status_code": response.get("statusCode") + } + ) + return response + except Exception as e: + logger.exception( + "Unhandled exception in lambda_handler", + extra={ + "request_id": request_id, + "http_method": http_method, + "user_id": user_id, + "fridge_id": fridge_id, + "path": path, + "error_type": type(e).__name__ + } + ) + return error_response(500, "Internal server error", ErrorCode.INTERNAL_SERVER_ERROR, request_id=request_id) diff --git a/user-fridge-notification-service/functions/fridge_notifications/user/requirements.txt b/user-fridge-notification-service/functions/fridge_notifications/user/requirements.txt new file mode 100644 index 0000000..3ddf790 --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_notifications/user/requirements.txt @@ -0,0 +1,4 @@ +# Shared deps provided by CommonLayer or Lambda runtime +# boto3 is provided by the Lambda runtime +pydantic>=2.0 +requests>=2.31.0 \ No newline at end of file diff --git a/user-fridge-notification-service/functions/fridge_report_stream_processor/__init__.py b/user-fridge-notification-service/functions/fridge_report_stream_processor/__init__.py new file mode 100644 index 0000000..12db0cb --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_report_stream_processor/__init__.py @@ -0,0 +1 @@ +# Init file for fridge_report_stream_processor diff --git a/user-fridge-notification-service/functions/fridge_report_stream_processor/app.py b/user-fridge-notification-service/functions/fridge_report_stream_processor/app.py new file mode 100644 index 0000000..9583b68 --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_report_stream_processor/app.py @@ -0,0 +1,73 @@ +import json +import logging +from typing import Dict, Any +from aws_lambda_powertools.utilities.data_classes import DynamoDBStreamEvent +from aws_lambda_powertools.utilities.typing import LambdaContext + +# Set up logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: + """ + Process DynamoDB Stream events from FridgeReportStream. + Logs the deserialized database data as JSON. + + Args: + event: DynamoDB Stream event + context: Lambda context + + Returns: + Dict with processing status + """ + # Parse the event using DynamoDBStreamEvent + stream_event = DynamoDBStreamEvent(event) + + # Convert records generator to list to get count + records = list(stream_event.records) + logger.info(f"Processing {len(records)} records from DynamoDB Stream") + + processed_records = [] + + for record in records: + logger.info(f"Event ID: {record.event_id}") + logger.info(f"Event Name: {record.event_name}") + logger.info(f"Event Source: {record.event_source}") + logger.info(f"Event Version: {record.event_version}") + + # Prepare record data + record_data = { + "event_id": record.event_id, + "event_name": record.event_name, + "event_source": record.event_source, + "aws_region": record.aws_region, + "dynamodb": {} + } + + # Get the DynamoDB data (stream only sends NEW_IMAGE) + if record.dynamodb and record.dynamodb.new_image: + new_image = record.dynamodb.new_image + record_data["dynamodb"]["new_image"] = new_image + + # Log the deserialized data as JSON + logger.info(f"Fridge Report Data (deserialized): {json.dumps(new_image, indent=2, default=str)}") + + # Log keys + if record.dynamodb.keys: + keys = record.dynamodb.keys + record_data["dynamodb"]["keys"] = keys + logger.info(f"Keys: {json.dumps(keys, indent=2, default=str)}") + + # Log the complete record as JSON + logger.info(f"Complete Record Data: {json.dumps(record_data, indent=2, default=str)}") + + processed_records.append(record_data) + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Successfully processed DynamoDB stream records", + "records_processed": len(processed_records) + }) + } diff --git a/user-fridge-notification-service/functions/fridge_report_stream_processor/requirements.txt b/user-fridge-notification-service/functions/fridge_report_stream_processor/requirements.txt new file mode 100644 index 0000000..05ccd11 --- /dev/null +++ b/user-fridge-notification-service/functions/fridge_report_stream_processor/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools>=2.0.0 diff --git a/Notification/functions/hello_world/__init__.py b/user-fridge-notification-service/functions/hello_world/__init__.py similarity index 100% rename from Notification/functions/hello_world/__init__.py rename to user-fridge-notification-service/functions/hello_world/__init__.py diff --git a/Notification/functions/hello_world/app.py b/user-fridge-notification-service/functions/hello_world/app.py similarity index 100% rename from Notification/functions/hello_world/app.py rename to user-fridge-notification-service/functions/hello_world/app.py diff --git a/Notification/functions/hello_world/requirements.txt b/user-fridge-notification-service/functions/hello_world/requirements.txt similarity index 100% rename from Notification/functions/hello_world/requirements.txt rename to user-fridge-notification-service/functions/hello_world/requirements.txt diff --git a/Notification/tests/__init__.py b/user-fridge-notification-service/functions/user_deletion_handler/__init__.py similarity index 100% rename from Notification/tests/__init__.py rename to user-fridge-notification-service/functions/user_deletion_handler/__init__.py diff --git a/user-fridge-notification-service/functions/user_deletion_handler/app.py b/user-fridge-notification-service/functions/user_deletion_handler/app.py new file mode 100644 index 0000000..d355dc8 --- /dev/null +++ b/user-fridge-notification-service/functions/user_deletion_handler/app.py @@ -0,0 +1,122 @@ +""" +Lambda function to handle user deletion events from EventBridge. +Deletes all notification entries for a deleted user. +""" +import os +import logging +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def get_ddb_connection(): + """Create a DynamoDB client; uses LocalStack when env is 'local'.""" + env = os.environ.get('DEPLOYMENT_TARGET', 'aws') + if env == 'aws': + return boto3.client('dynamodb') + else: + return boto3.client('dynamodb', endpoint_url='http://localstack:4566/') + +dynamodb = get_ddb_connection() +table_name = os.environ.get('TABLE_NAME') + +def lambda_handler(event, context): + """ + Processes user deletion events and removes all notifications for the user. + + Args: + event: EventBridge event containing userId in the detail + context: Lambda context object + + Returns: + dict: Summary with userId, totalCount, deletedCount, and failedCount + + Raises: + Exception: If any deletions failed, triggering EventBridge retry + """ + logger.info("Received user deletion event", extra={ + "event": event, + "eventType": "user_deletion" + }) + + try: + # Extract userId from EventBridge event + user_id = event['detail']['userId'] + logger.info("Starting user notification cleanup", extra={ + "userId": user_id, + "operation": "delete_user_notifications" + }) + + # Query all notifications for this user + response = dynamodb.query( + TableName=table_name, + KeyConditionExpression='userId=:userId', + ExpressionAttributeValues={ + ':userId': {'S': user_id} + } + ) + + items = response.get('Items', []) + deleted_count = 0 + failed_count = 0 + total_count = len(items) + # Delete each notification + for item in items: + try: + dynamodb.delete_item( + TableName=table_name, + Key={ + 'userId': {'S': item['userId']['S']}, + 'fridgeId': {'S': item['fridgeId']['S']} + } + ) + deleted_count += 1 + except ClientError as e: + failed_count += 1 + logger.error("Failed to delete notification", extra={ + "userId": user_id, + "fridgeId": item['fridgeId']['S'], + "error": str(e), + "operation": "delete_notification" + }) + + logger.info("User notification cleanup completed", extra={ + "userId": user_id, + "totalCount": total_count, + "deletedCount": deleted_count, + "failedCount": failed_count, + "operation": "delete_user_notifications", + "status": "success" if failed_count == 0 else "partial_failure" + }) + + # Raise exception if any deletions failed to trigger EventBridge retry + if failed_count > 0: + raise Exception(f"Failed to delete {failed_count} of {total_count} notifications for user {user_id}") + + return { + 'userId': user_id, + 'totalCount': total_count, + 'deletedCount': deleted_count, + 'failedCount': failed_count + } + + except KeyError as e: + error_msg = f"Missing required field in event: {str(e)}" + logger.error("Invalid event structure", extra={ + "error": error_msg, + "errorType": "KeyError", + "operation": "delete_user_notifications", + "status": "failed" + }) + raise + + except Exception as e: + error_msg = f"Error processing user deletion: {str(e)}" + logger.error("User deletion processing failed", extra={ + "error": error_msg, + "errorType": type(e).__name__, + "operation": "delete_user_notifications", + "status": "failed" + }) + raise diff --git a/user-fridge-notification-service/functions/user_deletion_handler/requirements.txt b/user-fridge-notification-service/functions/user_deletion_handler/requirements.txt new file mode 100644 index 0000000..eb9820e --- /dev/null +++ b/user-fridge-notification-service/functions/user_deletion_handler/requirements.txt @@ -0,0 +1 @@ +# boto3 is included in the Lambda runtime by default \ No newline at end of file diff --git a/Notification/local_requirements.txt b/user-fridge-notification-service/local_requirements.txt similarity index 100% rename from Notification/local_requirements.txt rename to user-fridge-notification-service/local_requirements.txt diff --git a/user-fridge-notification-service/samconfig.toml b/user-fridge-notification-service/samconfig.toml new file mode 100644 index 0000000..a16b274 --- /dev/null +++ b/user-fridge-notification-service/samconfig.toml @@ -0,0 +1,109 @@ +# samconfig.toml - SAM CLI configuration for guided and non-interactive deploys +# Replace the placeholder values (Hosted Zone ID, region, stack_name, etc.) +# Usage: `sam deploy --config-file samconfig.toml --config-env dev` + +version = 0.1 + +# ============================================================================= +# Development Environment +# Usage: sam deploy --config-env dev +# ============================================================================= + +[dev.validate.parameters] +lint = true + +[dev.build.parameters] +cached = true +parallel = true + +[dev.deploy] +[dev.deploy.parameters] +region = "us-east-1" +s3_bucket = "cfm-sam-artifacts" +s3_prefix = "user-fridge-notification-service/dev" +capabilities = "CAPABILITY_NAMED_IAM" +confirm_changeset = true +no_fail_on_empty_changeset = false +stack_name = "user-fridge-notification-service-dev" +parameter_overrides = [ + "DeploymentTarget=aws", + "Environment=dev", + "CFMHostedZoneId=", + "FirebaseProjectId=" # Replace with your actual Firebase Project ID +] + +tags = [ + "project=CFM", + "service=user-fridge-notification", + "env=dev" +] + + +# ============================================================================= +# Staging Environment +# Usage: sam deploy --config-env staging +# ============================================================================= + +[staging.validate.parameters] +lint = true + +[staging.build.parameters] +cached = true +parallel = true + +[staging.deploy] +[staging.deploy.parameters] +region = "us-east-1" +s3_bucket = "cfm-sam-artifacts" +s3_prefix = "user-fridge-notification-service/staging" +capabilities = "CAPABILITY_NAMED_IAM" +confirm_changeset = true +no_fail_on_empty_changeset = false +stack_name = "user-fridge-notification-service-staging" +parameter_overrides = [ + "DeploymentTarget=aws", + "Environment=staging", + "CFMHostedZoneId=", + "FirebaseProjectId=" # Replace with your actual Firebase Project ID +] + +tags = [ + "project=FridgeFinder", + "service=notifications", + "env=staging" +] + + +# ============================================================================= +# Production Environment +# Usage: sam deploy --config-env prod +# ============================================================================= + +[prod.validate.parameters] +lint = true + +[prod.build.parameters] +cached = true +parallel = true + +[prod.deploy] +[prod.deploy.parameters] +region = "us-east-1" +s3_bucket = "cfm-sam-artifacts" +s3_prefix = "user-fridge-notification-service/prod" +capabilities = "CAPABILITY_NAMED_IAM" +confirm_changeset = true +no_fail_on_empty_changeset = false +stack_name = "user-fridge-notification-service-prod" +parameter_overrides = [ + "DeploymentTarget=aws", + "Environment=prod", + "CFMHostedZoneId=", + "FirebaseProjectId=" # Replace with your actual Firebase Project ID +] + +tags = [ + "project=FridgeFinder", + "service=notifications", + "env=prod" +] diff --git a/user-fridge-notification-service/template.yaml b/user-fridge-notification-service/template.yaml new file mode 100644 index 0000000..9733d32 --- /dev/null +++ b/user-fridge-notification-service/template.yaml @@ -0,0 +1,373 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Notifications + + Sample SAM Template for FridgeFinder Notifications Service + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 60 + MemorySize: 256 + Environment: + Variables: + TABLE_NAME: !Sub user_fridge_notifications_${Environment} + DEPLOYMENT_TARGET: !Ref DeploymentTarget + ENVIRONMENT: !Ref Environment + FIREBASE_PROJECT_ID: !Ref FirebaseProjectId + +Parameters: + DeploymentTarget: + Type: String + Description: Choose between local or AWS + AllowedValues: + - local + - aws + Environment: + Type: String + Description: Choose between dev, staging, prod + AllowedValues: + - dev + - staging + - prod + CFMHostedZoneId: + Type: String + Description: Grab the HostedZoneId from Route53 console + FirebaseProjectId: + Type: String + Description: Firebase Project ID for authentication + + +Resources: + # SSL Certificate for custom domain + ApiCertificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: !Sub notifications-api-${Environment}.communityfridgefinder.com + DomainValidationOptions: + - DomainName: !Sub notifications-api-${Environment}.communityfridgefinder.com + HostedZoneId: !Sub ${CFMHostedZoneId} + ValidationMethod: DNS + # HTTP API + ApiGatewayApi: + Type: AWS::Serverless::HttpApi + Properties: + Description: "FridgeFinder Notifications API with Firebase Authentication" + StageName: !Ref Environment + Auth: + Authorizers: + FirebaseAuthorizer: + JwtConfiguration: + issuer: !Sub "https://securetoken.google.com/${FirebaseProjectId}" + audience: + - !Ref FirebaseProjectId + IdentitySource: $request.header.Authorization + DefaultAuthorizer: FirebaseAuthorizer + CorsConfiguration: + AllowMethods: + - GET + - POST + - PATCH + - DELETE + - OPTIONS + AllowHeaders: + - Content-Type + - Authorization + - X-Amz-Date + - X-Api-Key + - X-Amz-Security-Token + AllowOrigins: + - '*' + + # Custom Domain Name + ApiDomainName: + Type: AWS::ApiGatewayV2::DomainName + Properties: + DomainName: !Sub notifications-api-${Environment}.communityfridgefinder.com + DomainNameConfigurations: + - CertificateArn: !Ref ApiCertificate + EndpointType: REGIONAL + + # API Mapping + ApiMapping: + Type: AWS::ApiGatewayV2::ApiMapping + Properties: + DomainName: !Ref ApiDomainName + ApiId: !Ref ApiGatewayApi + Stage: !Ref ApiGatewayApi.Stage + + # Route53 DNS Record + ApiDnsRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref CFMHostedZoneId + Name: !Sub notifications-api-${Environment}.communityfridgefinder.com + Type: A + AliasTarget: + DNSName: !GetAtt ApiDomainName.RegionalDomainName + HostedZoneId: !GetAtt ApiDomainName.RegionalHostedZoneId + + ########################## + ## Lambda Functions ## + ########################## + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: functions/hello_world/ + Handler: app.lambda_handler + Runtime: python3.13 + Architectures: + - x86_64 + Events: + HelloWorld: + Type: HttpApi + Properties: + Path: /hello + Method: get + ApiId: + Ref: ApiGatewayApi + Auth: + Authorizer: NONE + + UserFridgeNotificationsFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fridge_notifications/user + Handler: app.lambda_handler + Runtime: python3.13 + FunctionName: !Sub UserFridgeNotificationsFunction${Environment} + Layers: + - !Ref CommonLayer + Policies: + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:DeleteItem + Resource: !GetAtt UserFridgeNotificationsTable.Arn + Events: + PostUserFridgeNotification: + Type: HttpApi + Properties: + Path: /v1/users/{user_id}/fridge-notifications/{fridge_id} + Method: post + ApiId: + Ref: ApiGatewayApi + PatchUserFridgeNotification: + Type: HttpApi + Properties: + Path: /v1/users/{user_id}/fridge-notifications/{fridge_id} + Method: patch + ApiId: + Ref: ApiGatewayApi + GetUserFridgeNotification: + Type: HttpApi + Properties: + Path: /v1/users/{user_id}/fridge-notifications/{fridge_id} + Method: get + ApiId: + Ref: ApiGatewayApi + DeleteUserFridgeNotification: + Type: HttpApi + Properties: + Path: /v1/users/{user_id}/fridge-notifications/{fridge_id} + Method: delete + ApiId: + Ref: ApiGatewayApi + + GetAllUserFridgeNotificationsFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fridge_notifications/getAllUserNotifications + Handler: app.lambda_handler + Runtime: python3.13 + FunctionName: !Sub GetAllUserFridgeNotificationsFunction${Environment} + Layers: + - !Ref CommonLayer + Policies: + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:Scan + - dynamodb:Query + Resource: + - !GetAtt UserFridgeNotificationsTable.Arn + - !Sub "${UserFridgeNotificationsTable.Arn}/index/*" + Events: + GetAllUserFridgeNotifications: + Type: HttpApi + Properties: + Path: /v1/users/{user_id}/fridge-notifications + Method: get + ApiId: + Ref: ApiGatewayApi + + FridgeReportStreamProcessorFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/fridge_report_stream_processor + Handler: app.lambda_handler + Runtime: python3.13 + FunctionName: !Sub FridgeReportStreamProcessorFunction${Environment} + Policies: + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:DescribeStream + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:ListStreams + Resource: + Fn::ImportValue: !Sub "FridgeReportStreamArn-${Environment}" + Events: + FridgeReportStream: + Type: DynamoDB + Properties: + Stream: + Fn::ImportValue: !Sub "FridgeReportStreamArn-${Environment}" + StartingPosition: LATEST + BatchSize: 1 + Enabled: true + + UserDeletionHandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/user_deletion_handler + Handler: app.lambda_handler + Runtime: python3.13 + FunctionName: !Sub UserDeletionHandlerFunction${Environment} + Policies: + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:Query + - dynamodb:DeleteItem + Resource: !GetAtt UserFridgeNotificationsTable.Arn + + ########################## + ## EventBridge Rule ## + ########################## + + UserDeletionEventRule: + Type: AWS::Events::Rule + Properties: + Name: !Sub user-deletion-notification-cleanup-${Environment} + Description: Triggers notification cleanup when a user is deleted + EventBusName: + Fn::ImportValue: !Sub user-service-${Environment}-EventBusName + EventPattern: + source: + - user-service + detail-type: + - User Deleted + State: ENABLED + Targets: + - Arn: !GetAtt UserDeletionHandlerFunction.Arn + Id: UserDeletionHandlerTarget + + UserDeletionEventRulePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref UserDeletionHandlerFunction + Action: lambda:InvokeFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt UserDeletionEventRule.Arn + + ########################## + ## DynamoDB Tables ## + ########################## + + UserFridgeNotificationsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub user_fridge_notifications_${Environment} + BillingMode: "PAY_PER_REQUEST" + AttributeDefinitions: + - AttributeName: "userId" + AttributeType: "S" + - AttributeName: "fridgeId" + AttributeType: "S" + KeySchema: + - AttributeName: "userId" + KeyType: "HASH" + - AttributeName: "fridgeId" + KeyType: "RANGE" + GlobalSecondaryIndexes: + - IndexName: FridgeIndex + KeySchema: + - AttributeName: fridgeId + KeyType: HASH + - AttributeName: userId + KeyType: RANGE + Projection: + ProjectionType: ALL + + ########################## + ## CommonLayer ## + ########################## + CommonLayer: + Type: AWS::Serverless::LayerVersion + Properties: + LayerName: !Sub FridgeNotificationsLayer${Environment} + Description: Dependencies for FridgeFinder Fridge Notifications service + ContentUri: dependencies/ + CompatibleRuntimes: + - python3.13 + RetentionPolicy: Delete + +Outputs: + # Find out more about other implicit resources you can reference within SAM + # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api + NotificationsApiBaseUrl: + Description: "Base URL for Notifications API (custom domain)" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/" + + NotificationsApiHelloWorld: + Description: "Hello World endpoint URL" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/hello" + + NotificationsApiGetUserFridgeNotification: + Description: "GET user fridge notification endpoint URL" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/v1/users/{user_id}/fridge-notifications/{fridge_id}" + + NotificationsApiPostUserFridgeNotification: + Description: "POST user fridge notification endpoint URL" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/v1/users/{user_id}/fridge-notifications/{fridge_id}" + + NotificationsApiPatchUserFridgeNotification: + Description: "PATCH user fridge notification endpoint URL" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/v1/users/{user_id}/fridge-notifications/{fridge_id}" + + NotificationsApiGetAllUserFridgeNotifications: + Description: "GET all user fridge notifications endpoint URL" + Value: !Sub "https://notifications-api-${Environment}.communityfridgefinder.com/v1/users/{user_id}/fridge-notifications" diff --git a/Notification/tests/integration/__init__.py b/user-fridge-notification-service/tests/__init__.py similarity index 100% rename from Notification/tests/integration/__init__.py rename to user-fridge-notification-service/tests/__init__.py diff --git a/Notification/tests/unit/__init__.py b/user-fridge-notification-service/tests/integration/__init__.py similarity index 100% rename from Notification/tests/unit/__init__.py rename to user-fridge-notification-service/tests/integration/__init__.py diff --git a/Notification/tests/integration/test_api_gateway.py b/user-fridge-notification-service/tests/integration/test_api_gateway.py similarity index 100% rename from Notification/tests/integration/test_api_gateway.py rename to user-fridge-notification-service/tests/integration/test_api_gateway.py diff --git a/Notification/tests/requirements.txt b/user-fridge-notification-service/tests/requirements.txt similarity index 100% rename from Notification/tests/requirements.txt rename to user-fridge-notification-service/tests/requirements.txt diff --git a/Notification/functions/fridge_notifications/getAllUserNotifications/requirements.txt b/user-fridge-notification-service/tests/unit/__init__.py similarity index 100% rename from Notification/functions/fridge_notifications/getAllUserNotifications/requirements.txt rename to user-fridge-notification-service/tests/unit/__init__.py diff --git a/Notification/tests/unit/test_handler.py b/user-fridge-notification-service/tests/unit/test_handler.py similarity index 100% rename from Notification/tests/unit/test_handler.py rename to user-fridge-notification-service/tests/unit/test_handler.py diff --git a/Notification/tests/unit/test_user_fridge_notifications_api.py b/user-fridge-notification-service/tests/unit/test_user_fridge_notifications_api.py similarity index 76% rename from Notification/tests/unit/test_user_fridge_notifications_api.py rename to user-fridge-notification-service/tests/unit/test_user_fridge_notifications_api.py index ea3c3be..c402132 100644 --- a/Notification/tests/unit/test_user_fridge_notifications_api.py +++ b/user-fridge-notification-service/tests/unit/test_user_fridge_notifications_api.py @@ -12,35 +12,33 @@ def setUp(self): self.user_id = "user_1" self.fridge_id = "fridge_1" self.model = UserFridgeNotificationModel( - user_id=self.user_id, - fridge_id=self.fridge_id, - contact_info={"sms": "+18575678902"}, - contact_types_preferences={ + userId=self.user_id, + fridgeId=self.fridge_id, + contactTypePreferences={ "sms": { "good": True, "dirty": True, - "out_of_order": True, - "not_at_location": True, + "outOfOrder": True, + "notAtLocation": True, "ghost": True, - "food_level_0": True, - "food_level_1": True, - "food_level_2": True, - "food_level_3": True, + "foodLevel0": True, + "foodLevel1": True, + "foodLevel2": True, + "foodLevel3": True, "cleaned": True } - }, - contact_types_status={"sms": "start"}, + } ) def test_get_user_fridge_notification_found(self): # Mock DynamoDB get_item response self.mock_db_client.get_item.return_value = { - "Item": {"user_id": {"S": self.user_id}, "fridge_id": {"S": self.fridge_id}, "contact_info": {"M": {}}} + "Item": {"userId": {"S": self.user_id}, "fridgeId": {"S": self.fridge_id}, "contact_info": {"M": {}}} } - with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"user_id": self.user_id, "fridge_id": self.fridge_id}): + with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"userId": self.user_id, "fridgeId": self.fridge_id}): response = self.api.get_user_fridge_notification(self.user_id, self.fridge_id) self.assertEqual(response.status_code, 200) - self.assertIn("user_id", response.body) + self.assertIn("userId", response.body) def test_get_user_fridge_notification_not_found(self): self.mock_db_client.get_item.return_value = {} @@ -49,7 +47,7 @@ def test_get_user_fridge_notification_not_found(self): def test_post_user_fridge_notification_success(self): self.mock_db_client.put_item.return_value = {} - mock_dict = {"user_id": self.user_id, "fridge_id": self.fridge_id} + mock_dict = {"userId": self.user_id, "fridgeId": self.fridge_id} with patch("Notification.dependencies.python.user_fridge_notifications_api.dict_to_dynamodb", return_value={}), \ patch("pydantic.BaseModel.model_dump", return_value=mock_dict): response = self.api.post_user_fridge_notification(self.model) @@ -61,7 +59,7 @@ def test_post_user_fridge_notification_conflict(self): error_response={"Error": {"Code": "ConditionalCheckFailedException"}}, operation_name="PutItem" ) - mock_dict = {"user_id": self.user_id, "fridge_id": self.fridge_id} + mock_dict = {"userId": self.user_id, "fridgeId": self.fridge_id} with patch("Notification.dependencies.python.user_fridge_notifications_api.dict_to_dynamodb", return_value={}), \ patch("pydantic.BaseModel.model_dump", return_value=mock_dict): response = self.api.post_user_fridge_notification(self.model) @@ -73,10 +71,10 @@ def test_put_user_fridge_notification_not_found(self): self.assertEqual(response.status_code, 404) def test_put_user_fridge_notification_success(self): - self.mock_db_client.get_item.return_value = {"Item": {"created_at": datetime.now(timezone.utc).isoformat()}} + self.mock_db_client.get_item.return_value = {"Item": {"createdAt": datetime.now(timezone.utc).isoformat()}} self.mock_db_client.put_item.return_value = {} - mock_dict = {"user_id": self.user_id, "fridge_id": self.fridge_id} - with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"created_at": datetime.now(timezone.utc).isoformat()}), \ + mock_dict = {"userId": self.user_id, "fridgeId": self.fridge_id} + with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"createdAt": datetime.now(timezone.utc).isoformat()}), \ patch("pydantic.BaseModel.model_dump", return_value=mock_dict): response = self.api.put_user_fridge_notification(self.model) self.assertEqual(response.status_code, 200) @@ -87,7 +85,7 @@ def test_post_user_fridge_notification_db_error(self): operation_name="PutItem" ) self.mock_db_client.put_item.side_effect = error - mock_dict = {"user_id": self.user_id, "fridge_id": self.fridge_id} + mock_dict = {"userId": self.user_id, "fridgeId": self.fridge_id} with patch("Notification.dependencies.python.user_fridge_notifications_api.dict_to_dynamodb", return_value={}), \ patch("pydantic.BaseModel.model_dump", return_value=mock_dict): response = self.api.post_user_fridge_notification(self.model) @@ -96,15 +94,15 @@ def test_post_user_fridge_notification_db_error(self): def test_put_user_fridge_notification_db_error(self): # Simulate existing item returned by get_item - self.mock_db_client.get_item.return_value = {"Item": {"created_at": datetime.now(timezone.utc).isoformat()}} + self.mock_db_client.get_item.return_value = {"Item": {"createdAt": datetime.now(timezone.utc).isoformat()}} # Simulate DynamoDB put_item raising a ClientError error = ClientError( error_response={"Error": {"Code": "InternalServerError", "Message": "Database Error"}}, operation_name="PutItem" ) self.mock_db_client.put_item.side_effect = error - mock_dict = {"user_id": self.user_id, "fridge_id": self.fridge_id} - with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"created_at": datetime.now(timezone.utc).isoformat()}), \ + mock_dict = {"userId": self.user_id, "fridgeId": self.fridge_id} + with patch("Notification.dependencies.python.user_fridge_notifications_api.dynamodb_to_dict", return_value={"createdAt": datetime.now(timezone.utc).isoformat()}), \ patch("pydantic.BaseModel.model_dump", return_value=mock_dict): response = self.api.put_user_fridge_notification(self.model) self.assertEqual(response.status_code, 500) @@ -117,11 +115,11 @@ def test_race_condition_put(self): fridge_id = "fridge1" updated_at = "2025-10-28T12:00:00Z" item = { - "user_id": {"S": user_id}, - "fridge_id": {"S": fridge_id}, - "updated_at": {"S": updated_at} + "userId": {"S": user_id}, + "fridgeId": {"S": fridge_id}, + "updatedAt": {"S": updated_at} } - # Mock get_item to return the same updated_at for both calls + # Mock get_item to return the same updatedAt for both calls api.db_client.get_item.return_value = {"Item": item} # First put_item succeeds @@ -139,9 +137,9 @@ def put_item_side_effect(*args, **kwargs): # Prepare model model = MagicMock() - model.user_id = user_id - model.fridge_id = fridge_id - model.model_dump.return_value = {"user_id": user_id, "fridge_id": fridge_id, "updated_at": updated_at} + model.userId = user_id + model.fridgeId = fridge_id + model.model_dump.return_value = {"userId": user_id, "fridgeId": fridge_id, "updatedAt": updated_at} # First update should succeed response1 = api.put_user_fridge_notification(model) diff --git a/user-fridge-notification-service/tests/unit/test_user_fridge_notifications_model.py b/user-fridge-notification-service/tests/unit/test_user_fridge_notifications_model.py new file mode 100644 index 0000000..c06e778 --- /dev/null +++ b/user-fridge-notification-service/tests/unit/test_user_fridge_notifications_model.py @@ -0,0 +1,221 @@ +import unittest +from datetime import datetime +from unittest.mock import patch +from Notification.dependencies.python.user_fridge_notifications_model import ( + UserFridgeNotificationModel, + ContactTypePreferencesModel, + FridgePreferencesModel, +) + +class TestUserFridgeNotificationModel(unittest.TestCase): + def setUp(self): + # reusable valid fridge preferences + self.fridge_prefs = FridgePreferencesModel( + good=True, + dirty=True, + outOfOrder=True, + notAtLocation=True, + ghost=True, + foodLevel0=True, + foodLevel1=True, + foodLevel2=True, + foodLevel3=True, + cleaned=True, + ) + + def test_model_happy_path(self): + contact_types_preferences = ContactTypePreferencesModel(sms=self.fridge_prefs, email=self.fridge_prefs) + + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_types_preferences, + ) + + self.assertEqual(model.userId, "user_123") + self.assertEqual(model.fridgeId, "fridge_123") + self.assertIsNotNone(model.contactTypePreferences) + self.assertEqual(model.contactTypePreferences.email.good, True) + + def test_empty_preferences(self): + # Test with empty preferences + contact_types_preferences = ContactTypePreferencesModel() + + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_types_preferences, + ) + + self.assertEqual(model.userId, "user_123") + self.assertEqual(model.fridgeId, "fridge_123") + self.assertIsNone(model.contactTypePreferences.email) + self.assertIsNone(model.contactTypePreferences.sms) + self.assertIsNone(model.contactTypePreferences.device) + + def test_patch_preferences_partial_field_update(self): + """Test updating only specific fields within a contact type""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Update only the 'dirty' field + model.patch_preferences({"email": {"dirty": False}}) + + # dirty should be updated + self.assertEqual(model.contactTypePreferences.email.dirty, False) + # all other fields should remain unchanged + self.assertEqual(model.contactTypePreferences.email.good, True) + self.assertEqual(model.contactTypePreferences.email.outOfOrder, True) + self.assertEqual(model.contactTypePreferences.email.foodLevel0, True) + + def test_patch_preferences_update_multiple_fields(self): + """Test updating multiple fields within a contact type""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Update multiple fields + model.patch_preferences({ + "email": { + "dirty": False, + "good": False, + "ghost": False + } + }) + + # Updated fields + self.assertEqual(model.contactTypePreferences.email.dirty, False) + self.assertEqual(model.contactTypePreferences.email.good, False) + self.assertEqual(model.contactTypePreferences.email.ghost, False) + # Unchanged fields + self.assertEqual(model.contactTypePreferences.email.outOfOrder, True) + self.assertEqual(model.contactTypePreferences.email.foodLevel0, True) + + def test_patch_preferences_add_new_contact_type(self): + """Test adding a new contact type while preserving existing ones""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Add device preferences + device_prefs = FridgePreferencesModel( + good=False, + dirty=False, + outOfOrder=False, + notAtLocation=False, + ghost=False, + foodLevel0=False, + foodLevel1=False, + foodLevel2=False, + foodLevel3=False, + ) + model.patch_preferences({"device": device_prefs.model_dump()}) + + # Email should remain unchanged + self.assertEqual(model.contactTypePreferences.email.good, True) + self.assertEqual(model.contactTypePreferences.email.dirty, True) + # Device should be added + self.assertIsNotNone(model.contactTypePreferences.device) + self.assertEqual(model.contactTypePreferences.device.good, False) + + def test_patch_preferences_remove_contact_type(self): + """Test removing a contact type by setting to None""" + contact_prefs = ContactTypePreferencesModel( + email=self.fridge_prefs, + device=self.fridge_prefs + ) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Remove email preferences + model.patch_preferences({"email": None}) + + # Email should be None + self.assertIsNone(model.contactTypePreferences.email) + # Device should remain + self.assertIsNotNone(model.contactTypePreferences.device) + self.assertEqual(model.contactTypePreferences.device.good, True) + + def test_patch_preferences_preserves_unchanged_contact_types(self): + """Test that unmentioned contact types remain unchanged""" + contact_prefs = ContactTypePreferencesModel( + email=self.fridge_prefs, + device=self.fridge_prefs + ) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Update only email + model.patch_preferences({"email": {"dirty": False}}) + + # Device should be completely unchanged + self.assertIsNotNone(model.contactTypePreferences.device) + self.assertEqual(model.contactTypePreferences.device.good, True) + self.assertEqual(model.contactTypePreferences.device.dirty, True) + + def test_patch_preferences_invalid_field_raises_validation_error(self): + """Test that invalid field names raise ValidationError""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Try to update with invalid contact type name (typo) + from pydantic import ValidationError + with self.assertRaises(ValidationError): + model.patch_preferences({"emal": {"dirty": False}}) + + def test_patch_preferences_invalid_field_in_preferences_raises_error(self): + """Test that invalid preference field names raise ValidationError""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + # Try to update with invalid field name within email preferences + from pydantic import ValidationError + with self.assertRaises(ValidationError): + model.patch_preferences({"email": {"invalidField": False}}) + + def test_patch_preferences_updates_timestamp(self): + """Test that patch_preferences updates the updatedAt timestamp""" + contact_prefs = ContactTypePreferencesModel(email=self.fridge_prefs) + model = UserFridgeNotificationModel( + userId="user_123", + fridgeId="fridge_123", + contactTypePreferences=contact_prefs, + ) + + original_updated_at = model.updatedAt + + # Wait a tiny bit and update + import time + time.sleep(0.01) + model.patch_preferences({"email": {"dirty": False}}) + + # updatedAt should have changed + self.assertNotEqual(model.updatedAt, original_updated_at) + + +if __name__ == "__main__": + unittest.main()