Skip to content

Commit 7a4e5bf

Browse files
authored
Merge pull request #232 from DevanshuNEU/feat/polish-sprint-launch
feat(landing): polish sprint - feedback widget, waitlist modal, theme fixes
2 parents 9f335e9 + 90a9c55 commit 7a4e5bf

25 files changed

Lines changed: 795 additions & 125 deletions

backend/.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,11 @@ SENTRY_DSN=
8686

8787
# Environment identifier
8888
ENVIRONMENT=development
89+
90+
# -----------------------------------------------------------------------------
91+
# Discord Webhooks - Feedback & Waitlist
92+
# -----------------------------------------------------------------------------
93+
94+
# Discord webhook for receiving user feedback and waitlist signups
95+
# Create at: Discord Server → Channel Settings → Integrations → Webhooks
96+
DISCORD_FEEDBACK_WEBHOOK=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token

backend/Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ EXPOSE 8000
2929
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
3030
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
3131

32-
# Run with uvicorn
33-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
32+
# Run with uvicorn (proxy-headers enabled for rate limiting behind reverse proxy)
33+
# Set FORWARDED_ALLOW_IPS env var in production to your proxy's IP range
34+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from routes.users import router as users_router
2929
from routes.search_v2 import router as search_v2_router
3030
from routes.github import router as github_router
31+
from routes.feedback import router as feedback_router
3132
from routes.ws_playground import websocket_playground_index
3233
from routes.ws_repos import websocket_repo_indexing
3334

@@ -94,6 +95,7 @@ async def dispatch(self, request: Request, call_next):
9495
app.include_router(users_router, prefix=API_PREFIX)
9596
app.include_router(search_v2_router, prefix=API_PREFIX)
9697
app.include_router(github_router, prefix=API_PREFIX)
98+
app.include_router(feedback_router, prefix=API_PREFIX)
9799

98100
# WebSocket endpoints (versioned)
99101
app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index)

backend/routes/feedback.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
Feedback routes - handles user feedback and waitlist signups
3+
Posts to Discord webhook server-side to keep webhook URL secret
4+
"""
5+
import os
6+
import httpx
7+
from datetime import datetime
8+
from fastapi import APIRouter, HTTPException, Request
9+
from pydantic import BaseModel, EmailStr
10+
from typing import Optional
11+
from services.rate_limiter import rate_limit
12+
13+
router = APIRouter(prefix="/feedback", tags=["feedback"])
14+
15+
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_FEEDBACK_WEBHOOK")
16+
17+
MOOD_CONFIG = {
18+
"frustrated": {"emoji": "😠", "color": 0xEF4444, "label": "Frustrated"},
19+
"meh": {"emoji": "😐", "color": 0xEAB308, "label": "Meh"},
20+
"good": {"emoji": "😊", "color": 0x22C55E, "label": "Good"},
21+
"love": {"emoji": "🤩", "color": 0x8B5CF6, "label": "Love it!"},
22+
}
23+
24+
25+
class FeedbackRequest(BaseModel):
26+
mood: str
27+
message: Optional[str] = None
28+
email: Optional[EmailStr] = None
29+
30+
31+
class WaitlistRequest(BaseModel):
32+
email: EmailStr
33+
plan: str # "pro" or "enterprise"
34+
35+
36+
async def post_to_discord(embed: dict) -> bool:
37+
"""Post an embed to Discord webhook."""
38+
if not DISCORD_WEBHOOK_URL:
39+
return False
40+
41+
try:
42+
async with httpx.AsyncClient() as client:
43+
response = await client.post(
44+
DISCORD_WEBHOOK_URL,
45+
json={"embeds": [embed]},
46+
timeout=10.0
47+
)
48+
return response.status_code == 204
49+
except Exception:
50+
return False
51+
52+
53+
@router.post("")
54+
@rate_limit(requests_per_minute=5)
55+
async def submit_feedback(request: Request, body: FeedbackRequest):
56+
"""Submit user feedback - posts to Discord."""
57+
if not DISCORD_WEBHOOK_URL:
58+
raise HTTPException(status_code=503, detail="Feedback service unavailable")
59+
60+
mood_info = MOOD_CONFIG.get(body.mood, MOOD_CONFIG["good"])
61+
62+
embed = {
63+
"title": "💬 New Feedback",
64+
"color": mood_info["color"],
65+
"fields": [
66+
{"name": "Mood", "value": f"{mood_info['emoji']} {mood_info['label']}", "inline": True},
67+
],
68+
"footer": {"text": "OpenCodeIntel Feedback"},
69+
"timestamp": datetime.utcnow().isoformat(),
70+
}
71+
72+
if body.email:
73+
embed["fields"].append({"name": "User", "value": body.email, "inline": True})
74+
75+
if body.message:
76+
embed["fields"].append({"name": "Message", "value": body.message[:1000], "inline": False})
77+
78+
success = await post_to_discord(embed)
79+
if not success:
80+
raise HTTPException(status_code=500, detail="Failed to submit feedback")
81+
82+
return {"success": True}
83+
84+
85+
@router.post("/waitlist")
86+
@rate_limit(requests_per_minute=3)
87+
async def join_waitlist(request: Request, body: WaitlistRequest):
88+
"""Join waitlist for Pro or Enterprise plan."""
89+
if not DISCORD_WEBHOOK_URL:
90+
raise HTTPException(status_code=503, detail="Waitlist service unavailable")
91+
92+
is_enterprise = body.plan.lower() == "enterprise"
93+
94+
if is_enterprise:
95+
embed = {
96+
"title": "🏢 Enterprise Inquiry",
97+
"color": 0x8B5CF6,
98+
"fields": [
99+
{"name": "Email", "value": body.email, "inline": True},
100+
{"name": "Plan", "value": "Enterprise (Custom)", "inline": True},
101+
],
102+
"footer": {"text": "OpenCodeIntel Enterprise"},
103+
"timestamp": datetime.utcnow().isoformat(),
104+
}
105+
else:
106+
embed = {
107+
"title": "🚀 New Waitlist Signup",
108+
"color": 0x3B82F6,
109+
"fields": [
110+
{"name": "Email", "value": body.email, "inline": True},
111+
{"name": "Plan Interest", "value": "Pro ($19/month)", "inline": True},
112+
],
113+
"footer": {"text": "OpenCodeIntel Waitlist"},
114+
"timestamp": datetime.utcnow().isoformat(),
115+
}
116+
117+
success = await post_to_discord(embed)
118+
if not success:
119+
raise HTTPException(status_code=500, detail="Failed to join waitlist")
120+
121+
return {"success": True}

backend/services/rate_limiter.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,84 @@
33
Prevents abuse and manages request quotas
44
"""
55
import time
6-
from typing import Optional, Dict
6+
from typing import Optional, Dict, Callable
77
from datetime import datetime, timedelta
8+
from functools import wraps
89
import hashlib
910
import secrets
1011
from dataclasses import dataclass
12+
from fastapi import HTTPException, Request
13+
14+
15+
# In-memory rate limit storage (per-process, resets on restart)
16+
_rate_limit_store: Dict[str, list] = {}
17+
_last_cleanup: float = 0.0
18+
_CLEANUP_INTERVAL_SEC = 60
19+
20+
21+
def rate_limit(requests_per_minute: int = 60):
22+
"""
23+
Simple rate limit decorator for FastAPI routes.
24+
Uses in-memory storage - suitable for single-instance deployments.
25+
For production, use Redis-backed RateLimiter class instead.
26+
27+
IMPORTANT: Routes using this decorator MUST include `request: Request` as
28+
a parameter. For correct client IP detection behind a reverse proxy,
29+
configure Uvicorn with:
30+
--proxy-headers --forwarded-allow-ips="<your-proxy-ips>"
31+
This ensures request.client.host reflects the real client IP from
32+
X-Forwarded-For header, not the proxy's IP.
33+
"""
34+
def decorator(func: Callable):
35+
@wraps(func)
36+
async def wrapper(*args, **kwargs):
37+
# Get Request from kwargs (FastAPI injects it by parameter name)
38+
request = kwargs.get('request')
39+
if not request:
40+
for arg in args:
41+
if isinstance(arg, Request):
42+
request = arg
43+
break
44+
45+
if not request or not isinstance(request, Request):
46+
raise HTTPException(
47+
status_code=500,
48+
detail="Rate limiting requires Request parameter in route"
49+
)
50+
51+
# Get client IP (relies on Uvicorn proxy-headers config for real IP)
52+
client_id = request.client.host if request.client else "unknown"
53+
54+
key = f"{func.__name__}:{client_id}"
55+
now = time.time()
56+
window_start = now - 60
57+
58+
# Periodic cleanup of stale keys to prevent unbounded memory growth
59+
global _last_cleanup
60+
if now - _last_cleanup > _CLEANUP_INTERVAL_SEC:
61+
for k in list(_rate_limit_store.keys()):
62+
timestamps = _rate_limit_store.get(k, [])
63+
if not timestamps or timestamps[-1] <= window_start:
64+
_rate_limit_store.pop(k, None)
65+
_last_cleanup = now
66+
67+
# Clean old entries and get current count
68+
if key not in _rate_limit_store:
69+
_rate_limit_store[key] = []
70+
71+
_rate_limit_store[key] = [t for t in _rate_limit_store[key] if t > window_start]
72+
73+
if len(_rate_limit_store[key]) >= requests_per_minute:
74+
raise HTTPException(
75+
status_code=429,
76+
detail=f"Rate limit exceeded. Max {requests_per_minute} requests per minute."
77+
)
78+
79+
_rate_limit_store[key].append(now)
80+
return await func(*args, **kwargs)
81+
82+
return wrapper
83+
return decorator
1184

1285

1386
@dataclass

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ services:
3636
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
3737
- API_KEY=${API_KEY}
3838
- BACKEND_API_URL=http://backend:8000
39+
- DISCORD_FEEDBACK_WEBHOOK=${DISCORD_FEEDBACK_WEBHOOK}
40+
- FORWARDED_ALLOW_IPS=*
3941
volumes:
4042
- ./backend/repos:/app/repos
4143
- ./backend:/app

0 commit comments

Comments
 (0)