diff --git a/.env.template b/.env.template index 9f572aa3..0dc5458c 100644 --- a/.env.template +++ b/.env.template @@ -17,6 +17,7 @@ NOTION_DATABASE_ID= # for calendar integration OPEN_ROUTER_CLAUDE_API_KEY= # for ai content generation DISCORD_OFFICER_WEBHOOK_URL= # for officer notifications DISCORD_POST_WEBHOOK_URL= # to post marketing announcements +DISCORD_STORE_WEBHOOK_URL= # for storefront purchase notifications ONEUP_EMAIL= # for social media cross-posting via selenium ONEUP_PASSWORD= # for social media cross-posting via selenium BOT_TOKEN= # for discord bot diff --git a/modules/storefront/api.py b/modules/storefront/api.py index bd4eeb56..6eb147b0 100644 --- a/modules/storefront/api.py +++ b/modules/storefront/api.py @@ -1,15 +1,24 @@ +import os +import threading from datetime import UTC, datetime +import requests as http_requests from flask import Blueprint, jsonify, request from sqlalchemy import func from modules.auth.decoraters import auth_required, dual_auth_required, error_handler, member_required from modules.storefront.models import Order, OrderItem, Product from modules.utils.db import DBConnect +from modules.utils.logging_config import get_logger + +logger = get_logger(__name__) storefront_blueprint = Blueprint("storefront", __name__) db_connect = DBConnect() +DISCORD_EMBED_FIELD_VALUE_LIMIT = 1024 +PURCHASE_WEBHOOK_THREAD_NAME = "storefront-purchase-webhook" + # Helper function to get organization by prefix def get_organization_by_prefix(db, org_prefix): @@ -31,6 +40,80 @@ def normalize_category(value): return value +def _build_item_lines_for_discord(items): + """Build a Discord-safe item list value for an embed field (max 1024 chars).""" + if not items: + return "No items" + + lines = [f"• {item['name']} x{item['quantity']} — {int(item['price'])} pts each" for item in items] + item_lines = "\n".join(lines) + if len(item_lines) <= DISCORD_EMBED_FIELD_VALUE_LIMIT: + return item_lines + + kept_lines = [] + for index, line in enumerate(lines): + remaining = len(lines) - (index + 1) + suffix = f"\n...and {remaining} more" if remaining else "" + candidate = "\n".join([*kept_lines, line]) + suffix + if len(candidate) > DISCORD_EMBED_FIELD_VALUE_LIMIT: + break + kept_lines.append(line) + + omitted_count = len(lines) - len(kept_lines) + suffix = f"\n...and {omitted_count} more" if omitted_count else "" + base_value = "\n".join(kept_lines) + + if not base_value: + max_prefix_len = max(DISCORD_EMBED_FIELD_VALUE_LIMIT - len(suffix), 0) + truncated_prefix = lines[0][:max_prefix_len] + return f"{truncated_prefix}{suffix}"[:DISCORD_EMBED_FIELD_VALUE_LIMIT] + + return f"{base_value}{suffix}"[:DISCORD_EMBED_FIELD_VALUE_LIMIT] + + +def send_purchase_webhook(order_id, user_email, items, total_amount, org_name): + """Send a Discord webhook notification for a storefront purchase. + + Runs in a background thread so the API response is not delayed. + """ + + def _send(): + webhook_url = os.environ.get("DISCORD_STORE_WEBHOOK_URL", "") + + if not webhook_url: + logger.debug("DISCORD_STORE_WEBHOOK_URL not configured, skipping purchase webhook") + return + + item_lines = _build_item_lines_for_discord(items) + + payload = { + "embeds": [ + { + "title": "🛒 New Storefront Purchase", + "color": 0x57F287, + "fields": [ + {"name": "Order", "value": f"#{order_id}", "inline": True}, + {"name": "Buyer", "value": user_email, "inline": True}, + {"name": "Organization", "value": org_name, "inline": True}, + {"name": "Items", "value": item_lines, "inline": False}, + {"name": "Total", "value": f"{int(total_amount)} pts", "inline": True}, + ], + } + ] + } + + try: + resp = http_requests.post(webhook_url, json=payload, timeout=5) + if resp.status_code >= 400: + logger.warning("Discord purchase webhook returned status %s", resp.status_code) + except Exception: + logger.exception("Failed to send Discord purchase webhook") + + thread = threading.Thread(target=_send, daemon=True, name=PURCHASE_WEBHOOK_THREAD_NAME) + thread.start() + return thread + + # PRODUCT ENDPOINTS @storefront_blueprint.route("//products", methods=["GET"]) @error_handler @@ -363,6 +446,7 @@ def create_order(org_prefix): # Prepare order items and validate stock order_items = [] + webhook_items = [] for item in data["items"]: if not all(k in item for k in ["product_id", "quantity", "price"]): return jsonify({"error": "Each item must have product_id, quantity, and price"}), 400 @@ -374,15 +458,18 @@ def create_order(org_prefix): return jsonify({"error": f"Insufficient stock for product {product.name}"}), 400 # Update stock - product.stock -= int(item["quantity"]) + quantity = int(item["quantity"]) + price_at_time = float(item["price"]) + product.stock -= quantity order_items.append( OrderItem( product_id=int(item["product_id"]), - quantity=int(item["quantity"]), - price_at_time=float(item["price"]), + quantity=quantity, + price_at_time=price_at_time, ) ) + webhook_items.append({"name": product.name, "quantity": quantity, "price": price_at_time}) # Create order new_order = Order(user_id=user.id, total_amount=total_amount, status="completed") @@ -402,6 +489,9 @@ def create_order(org_prefix): db.add(point_deduction) db.commit() + # Send Discord webhook notification (non-blocking) + send_purchase_webhook(created_order.id, user_email, webhook_items, total_amount, org.name) + return jsonify( { "message": "Order placed and points deducted successfully", @@ -1007,6 +1097,7 @@ def clerk_checkout(org_prefix): return jsonify({"error": f"Insufficient points. You have {points_sum} points but need {total_amount}"}), 400 order_items = [] + checkout_webhook_items = [] for item in data["items"]: if not all(k in item for k in ["product_id", "quantity", "price"]): return jsonify({"error": "Each item must have product_id, quantity, and price"}), 400 @@ -1017,15 +1108,18 @@ def clerk_checkout(org_prefix): if product.stock < int(item["quantity"]): return jsonify({"error": f"Insufficient stock for product {product.name}"}), 400 - product.stock -= int(item["quantity"]) + quantity = int(item["quantity"]) + price_at_time = float(item["price"]) + product.stock -= quantity order_items.append( OrderItem( product_id=int(item["product_id"]), - quantity=int(item["quantity"]), - price_at_time=float(item["price"]), + quantity=quantity, + price_at_time=price_at_time, ) ) + checkout_webhook_items.append({"name": product.name, "quantity": quantity, "price": price_at_time}) new_order = Order(user_id=user.id, total_amount=total_amount, status="completed") created_order = db_connect.create_storefront_order(db, new_order, order_items, org.id) @@ -1041,6 +1135,9 @@ def clerk_checkout(org_prefix): db.add(point_deduction) db.commit() + # Send Discord webhook notification (non-blocking) + send_purchase_webhook(created_order.id, user_email, checkout_webhook_items, total_amount, org.name) + return jsonify( { "message": "Order placed and points deducted successfully", diff --git a/modules/utils/config.py b/modules/utils/config.py index 8fc06dd7..758af88f 100644 --- a/modules/utils/config.py +++ b/modules/utils/config.py @@ -1,92 +1,93 @@ -import json -import os - -from dotenv import load_dotenv - -from modules.utils.logging_config import get_logger - -logger = get_logger(__name__) - - -class Config: - """Centralized configuration management for the application""" - - def __init__(self) -> None: - load_dotenv() - try: - # Core Application Config - self.SECRET_KEY = os.environ.get("SECRET_KEY", "test-secret-key") - self.CLIENT_ID = os.environ.get("CLIENT_ID", "test-client-id") - self.CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "test-client-secret") - self.REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:5000/callback") - self.CLIENT_URL = os.environ.get("CLIENT_URL", "http://localhost:3000") - self.TNAY_API_URL = os.environ.get("TNAY_API_URL", "") - self.OPEN_ROUTER_CLAUDE_API_KEY = os.environ.get("OPEN_ROUTER_CLAUDE_API_KEY", "") - self.DISCORD_OFFICER_WEBHOOK_URL = os.environ.get("DISCORD_OFFICER_WEBHOOK_URL", "") - self.DISCORD_POST_WEBHOOK_URL = os.environ.get("DISCORD_POST_WEBHOOK_URL", "") - self.ONEUP_PASSWORD = os.environ.get("ONEUP_PASSWORD", "") - self.ONEUP_EMAIL = os.environ.get("ONEUP_EMAIL", "") - self.PROD = os.environ.get("PROD", "false").lower() == "true" - - # Service Tokens - self.BOT_TOKEN = os.environ.get("BOT_TOKEN") - self.AVERY_BOT_TOKEN = os.environ.get("AVERY_BOT_TOKEN") - self.AUTH_BOT_TOKEN = os.environ.get("AUTH_BOT_TOKEN") - - # Auth - self.CLERK_SECRET_KEY = os.environ.get("CLERK_SECRET_KEY", "test-clerk-secret") - self.CLERK_AUTHORIZED_PARTIES = os.environ.get( - "CLERK_AUTHORIZED_PARTIES", "http://localhost:3000,http://localhost:5173" - ) - - # Database Configuration - self.DB_TYPE = os.environ.get("DB_TYPE", "sqlite") - self.DB_URI = os.environ.get("DB_URI", "sqlite:///test.db") - self.DB_NAME = os.environ.get("DB_NAME", "test") - self.DB_USER = os.environ.get("DB_USER", "test") - self.DB_PASSWORD = os.environ.get("DB_PASSWORD", "test") - self.DB_HOST = os.environ.get("DB_HOST", "localhost") - self.DB_PORT = os.environ.get("DB_PORT", "5432") - - # Google Calendar Integration - try: - with open("google-secret.json") as file: - self.GOOGLE_SERVICE_ACCOUNT = json.load(file) - logger.info("Google service account credentials loaded successfully") - except FileNotFoundError: - logger.warning("google-secret.json not found. Google Calendar features will be disabled.") - self.GOOGLE_SERVICE_ACCOUNT = None - except Exception as e: - logger.warning(f"Error loading Google credentials: {e}. Google Calendar features will be disabled.") - self.GOOGLE_SERVICE_ACCOUNT = None - - self.NOTION_API_KEY = os.environ.get("NOTION_API_KEY", "") - self.NOTION_DATABASE_ID = os.environ.get("NOTION_DATABASE_ID", "") - self.NOTION_TOKEN = os.environ.get("NOTION_TOKEN") - self.GOOGLE_CALENDAR_ID = os.environ.get("GOOGLE_CALENDAR_ID", "") - self.GOOGLE_USER_EMAIL = os.environ.get("GOOGLE_USER_EMAIL", "") - self.SERVER_PORT = int(os.environ.get("SERVER_PORT", "5000")) - self.SERVER_DEBUG = os.environ.get("SERVER_DEBUG", "false").lower() == "true" - self.TIMEZONE = os.environ.get("TIMEZONE", "America/Phoenix") - - # Monitoring Configuration (Optional) - self.SENTRY_DSN = os.environ.get("SENTRY_DSN") - self.SYS_ADMIN = os.environ.get("ADMIN_USER_ID") - - # AI Service Keys - self.GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") - - # Superadmin config - self.SUPERADMIN_USER_ID = os.environ.get("SYS_ADMIN") - - except json.JSONDecodeError as e: - raise RuntimeError(f"Configuration error: {str(e)}") from e - - @property - def google_calendar_config(self) -> dict: - """Get Google Calendar configuration as a dictionary""" - return { - "service_account": self.GOOGLE_SERVICE_ACCOUNT, - "calendar_id": self.GOOGLE_CALENDAR_ID, - "user_email": self.GOOGLE_USER_EMAIL, - } +import json +import os + +from dotenv import load_dotenv + +from modules.utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class Config: + """Centralized configuration management for the application""" + + def __init__(self) -> None: + load_dotenv() + try: + # Core Application Config + self.SECRET_KEY = os.environ.get("SECRET_KEY", "test-secret-key") + self.CLIENT_ID = os.environ.get("CLIENT_ID", "test-client-id") + self.CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "test-client-secret") + self.REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:5000/callback") + self.CLIENT_URL = os.environ.get("CLIENT_URL", "http://localhost:3000") + self.TNAY_API_URL = os.environ.get("TNAY_API_URL", "") + self.OPEN_ROUTER_CLAUDE_API_KEY = os.environ.get("OPEN_ROUTER_CLAUDE_API_KEY", "") + self.DISCORD_OFFICER_WEBHOOK_URL = os.environ.get("DISCORD_OFFICER_WEBHOOK_URL", "") + self.DISCORD_POST_WEBHOOK_URL = os.environ.get("DISCORD_POST_WEBHOOK_URL", "") + self.DISCORD_STORE_WEBHOOK_URL = os.environ.get("DISCORD_STORE_WEBHOOK_URL", "") + self.ONEUP_PASSWORD = os.environ.get("ONEUP_PASSWORD", "") + self.ONEUP_EMAIL = os.environ.get("ONEUP_EMAIL", "") + self.PROD = os.environ.get("PROD", "false").lower() == "true" + + # Service Tokens + self.BOT_TOKEN = os.environ.get("BOT_TOKEN") + self.AVERY_BOT_TOKEN = os.environ.get("AVERY_BOT_TOKEN") + self.AUTH_BOT_TOKEN = os.environ.get("AUTH_BOT_TOKEN") + + # Auth + self.CLERK_SECRET_KEY = os.environ.get("CLERK_SECRET_KEY", "test-clerk-secret") + self.CLERK_AUTHORIZED_PARTIES = os.environ.get( + "CLERK_AUTHORIZED_PARTIES", "http://localhost:3000,http://localhost:5173" + ) + + # Database Configuration + self.DB_TYPE = os.environ.get("DB_TYPE", "sqlite") + self.DB_URI = os.environ.get("DB_URI", "sqlite:///test.db") + self.DB_NAME = os.environ.get("DB_NAME", "test") + self.DB_USER = os.environ.get("DB_USER", "test") + self.DB_PASSWORD = os.environ.get("DB_PASSWORD", "test") + self.DB_HOST = os.environ.get("DB_HOST", "localhost") + self.DB_PORT = os.environ.get("DB_PORT", "5432") + + # Google Calendar Integration + try: + with open("google-secret.json") as file: + self.GOOGLE_SERVICE_ACCOUNT = json.load(file) + logger.info("Google service account credentials loaded successfully") + except FileNotFoundError: + logger.warning("google-secret.json not found. Google Calendar features will be disabled.") + self.GOOGLE_SERVICE_ACCOUNT = None + except Exception as e: + logger.warning(f"Error loading Google credentials: {e}. Google Calendar features will be disabled.") + self.GOOGLE_SERVICE_ACCOUNT = None + + self.NOTION_API_KEY = os.environ.get("NOTION_API_KEY", "") + self.NOTION_DATABASE_ID = os.environ.get("NOTION_DATABASE_ID", "") + self.NOTION_TOKEN = os.environ.get("NOTION_TOKEN") + self.GOOGLE_CALENDAR_ID = os.environ.get("GOOGLE_CALENDAR_ID", "") + self.GOOGLE_USER_EMAIL = os.environ.get("GOOGLE_USER_EMAIL", "") + self.SERVER_PORT = int(os.environ.get("SERVER_PORT", "5000")) + self.SERVER_DEBUG = os.environ.get("SERVER_DEBUG", "false").lower() == "true" + self.TIMEZONE = os.environ.get("TIMEZONE", "America/Phoenix") + + # Monitoring Configuration (Optional) + self.SENTRY_DSN = os.environ.get("SENTRY_DSN") + self.SYS_ADMIN = os.environ.get("ADMIN_USER_ID") + + # AI Service Keys + self.GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") + + # Superadmin config + self.SUPERADMIN_USER_ID = os.environ.get("SYS_ADMIN") + + except json.JSONDecodeError as e: + raise RuntimeError(f"Configuration error: {str(e)}") from e + + @property + def google_calendar_config(self) -> dict: + """Get Google Calendar configuration as a dictionary""" + return { + "service_account": self.GOOGLE_SERVICE_ACCOUNT, + "calendar_id": self.GOOGLE_CALENDAR_ID, + "user_email": self.GOOGLE_USER_EMAIL, + } diff --git a/pyproject.toml b/pyproject.toml index d0d31b79..f30680d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,3 +121,6 @@ ignore = [ [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.bandit] +exclude_dirs = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_purchase_webhook.py b/tests/test_purchase_webhook.py new file mode 100644 index 00000000..f3fa6fc2 --- /dev/null +++ b/tests/test_purchase_webhook.py @@ -0,0 +1,147 @@ +"""Tests for the storefront purchase Discord webhook notification.""" + +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + + +@contextmanager +def _temporary_storefront_import_stubs(): + """Temporarily stub modules that trigger heavy side-effects on import.""" + import sys + import types + + fake_shared = types.ModuleType("shared") + fake_shared.config = MagicMock() # type: ignore[attr-defined] + fake_shared.tokenManager = MagicMock() # type: ignore[attr-defined] + fake_decoraters = types.ModuleType("modules.auth.decoraters") + for name in ("auth_required", "dual_auth_required", "error_handler", "member_required"): + setattr(fake_decoraters, name, lambda f: f) + fake_db = types.ModuleType("modules.utils.db") + fake_db.DBConnect = MagicMock # type: ignore[attr-defined] + + replacements = { + "shared": fake_shared, + "modules.auth.decoraters": fake_decoraters, + "modules.utils.db": fake_db, + } + originals = {name: sys.modules.get(name) for name in replacements} + try: + for name, module in replacements.items(): + sys.modules[name] = module + yield + finally: + for name, original in originals.items(): + if original is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +def _make_send_purchase_webhook(): + """Create a standalone version of send_purchase_webhook for testing.""" + import importlib + import sys + + original_storefront_api = sys.modules.get("modules.storefront.api") + with _temporary_storefront_import_stubs(): + mod = importlib.import_module("modules.storefront.api") + send_webhook = mod.send_purchase_webhook + + if original_storefront_api is None: + sys.modules.pop("modules.storefront.api", None) + else: + sys.modules["modules.storefront.api"] = original_storefront_api + + return send_webhook + + +send_purchase_webhook = _make_send_purchase_webhook() + + +class TestSendPurchaseWebhook: + """Tests for the send_purchase_webhook helper function.""" + + @patch.object(send_purchase_webhook.__globals__["http_requests"], "post") + @patch.dict("os.environ", {"DISCORD_STORE_WEBHOOK_URL": "https://discord.com/api/webhooks/test"}) + def test_webhook_sends_correct_payload(self, mock_post): + """Webhook should POST a Discord embed with order details.""" + mock_post.return_value = MagicMock(status_code=204) + + items = [ + {"name": "T-Shirt", "quantity": 2, "price": 50}, + {"name": "Sticker", "quantity": 1, "price": 10}, + ] + + webhook_thread = send_purchase_webhook(42, "buyer@example.com", items, 110, "TestOrg") + webhook_thread.join(timeout=5) + assert webhook_thread.name == "storefront-purchase-webhook" # nosec B101 + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1]["json"] + + assert len(payload["embeds"]) == 1 # nosec B101 + embed = payload["embeds"][0] + assert "New Storefront Purchase" in embed["title"] # nosec B101 + + fields = {f["name"]: f["value"] for f in embed["fields"]} + assert fields["Order"] == "#42" # nosec B101 + assert fields["Buyer"] == "buyer@example.com" # nosec B101 + assert fields["Organization"] == "TestOrg" # nosec B101 + assert "T-Shirt" in fields["Items"] # nosec B101 + assert "Sticker" in fields["Items"] # nosec B101 + assert "110" in fields["Total"] # nosec B101 + + @patch.object(send_purchase_webhook.__globals__["http_requests"], "post") + @patch.dict("os.environ", {"DISCORD_STORE_WEBHOOK_URL": ""}) + def test_webhook_skipped_when_url_not_configured(self, mock_post): + """Webhook should not fire when DISCORD_STORE_WEBHOOK_URL is empty.""" + webhook_thread = send_purchase_webhook(1, "user@example.com", [], 0, "Org") + webhook_thread.join(timeout=5) + + mock_post.assert_not_called() + + @patch.object(send_purchase_webhook.__globals__["http_requests"], "post") + @patch.dict("os.environ", {"DISCORD_STORE_WEBHOOK_URL": "https://discord.com/api/webhooks/test"}) + def test_webhook_does_not_raise_on_http_error(self, mock_post): + """Webhook failures should be logged, not raised.""" + mock_post.side_effect = Exception("connection error") + + # Should not raise + webhook_thread = send_purchase_webhook( + 1, "user@example.com", [{"name": "Hat", "quantity": 1, "price": 20}], 20, "Org" + ) + webhook_thread.join(timeout=5) + + @patch.object(send_purchase_webhook.__globals__["http_requests"], "post") + @patch.dict("os.environ", {}, clear=False) + def test_webhook_skipped_when_env_var_missing(self, mock_post): + """Webhook should not fire when env var is not set at all.""" + import os + + os.environ.pop("DISCORD_STORE_WEBHOOK_URL", None) + + webhook_thread = send_purchase_webhook(1, "user@example.com", [], 0, "Org") + webhook_thread.join(timeout=5) + + mock_post.assert_not_called() + + @patch.object(send_purchase_webhook.__globals__["http_requests"], "post") + @patch.dict("os.environ", {"DISCORD_STORE_WEBHOOK_URL": "https://discord.com/api/webhooks/test"}) + def test_items_field_is_truncated_to_discord_limit(self, mock_post): + """Item field should stay within Discord's 1024 char embed field limit.""" + mock_post.return_value = MagicMock(status_code=204) + + long_name = "A" * 500 + items = [ + {"name": long_name, "quantity": 1, "price": 10}, + {"name": long_name, "quantity": 2, "price": 20}, + {"name": long_name, "quantity": 3, "price": 30}, + ] + + webhook_thread = send_purchase_webhook(99, "buyer@example.com", items, 60, "TestOrg") + webhook_thread.join(timeout=5) + + payload = mock_post.call_args.kwargs["json"] + fields = {f["name"]: f["value"] for f in payload["embeds"][0]["fields"]} + assert len(fields["Items"]) <= 1024 # nosec B101