Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
185 changes: 93 additions & 92 deletions modules/utils/config.py
Original file line number Diff line number Diff line change
@@ -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,
}
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.
Loading