Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 103 additions & 6 deletions modules/storefront/api.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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("/<string:org_prefix>/products", methods=["GET"])
@error_handler
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions modules/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def __init__(self, testing: bool = False) -> None:
# Optional configs
self.SENTRY_DSN = None
self.GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "test-gemini-key")
self.DISCORD_STORE_WEBHOOK_URL = os.environ.get("DISCORD_STORE_WEBHOOK_URL", "")

# Superadmin config
self.SUPERADMIN_USER_ID = os.environ.get("SYS_ADMIN", "test-superadmin-id")
Expand All @@ -73,6 +74,7 @@ def __init__(self, testing: bool = False) -> None:
self.OPEN_ROUTER_CLAUDE_API_KEY = os.environ["OPEN_ROUTER_CLAUDE_API_KEY"]
self.DISCORD_OFFICER_WEBHOOK_URL = os.environ["DISCORD_OFFICER_WEBHOOK_URL"]
self.DISCORD_POST_WEBHOOK_URL = os.environ["DISCORD_POST_WEBHOOK_URL"]
self.DISCORD_STORE_WEBHOOK_URL = os.environ.get("DISCORD_STORE_WEBHOOK_URL", "")
self.ONEUP_PASSWORD = os.environ["ONEUP_PASSWORD"]
self.ONEUP_EMAIL = os.environ["ONEUP_EMAIL"]
self.PROD = os.environ.get("PROD", "false").lower() == "true"
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,6 @@ ignore = [
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.bandit]
exclude_dirs = ["tests"]
Empty file added tests/__init__.py
Empty file.
147 changes: 147 additions & 0 deletions tests/test_purchase_webhook.py
Original file line number Diff line number Diff line change
@@ -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("modules.storefront.api.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("modules.storefront.api.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("modules.storefront.api.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("modules.storefront.api.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("modules.storefront.api.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
Loading