Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ python3 -m pip install -e ".[dev]"
```

Create `.env` from `.env.example`, then fill `JWT_SECRET_KEY`, `GOOGLE_CLIENT_ID`, and `YOUTUBE_API_KEY`.
Set `DISCORD_WAITLIST_WEBHOOK_URL` too if you want waitlist signups to post to Discord.

Run database migrations:

Expand Down
13 changes: 13 additions & 0 deletions app/api/waitlist.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import logging

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.api.users import get_current_user
from app.core.config import get_settings
from app.db.session import get_db
from app.models.user import User
from app.models.waitlist import WaitlistEntry
from app.schemas.waitlist import WaitlistCreateRequest, WaitlistEntryResponse
from app.services.discord import notify_waitlist_signup

router = APIRouter(prefix="/waitlist", tags=["waitlist"])
logger = logging.getLogger(__name__)


@router.post("", response_model=WaitlistEntryResponse)
Expand All @@ -27,4 +32,12 @@ def create_waitlist_entry(
db.add(entry)
db.commit()
db.refresh(entry)

webhook_url = get_settings().discord_waitlist_webhook_url
if webhook_url:
try:
notify_waitlist_signup(entry, webhook_url)
except Exception:
logger.exception("Failed to notify Discord for waitlist signup", extra={"waitlist_entry_id": entry.id})

return entry
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Settings(BaseSettings):
gemini_api_key: str = ""
gemini_model: str = "gemini-2.5-flash"
supadata_api_key: str = ""
discord_waitlist_webhook_url: str = ""
cors_origins: str = (
"http://localhost:3000,"
"http://127.0.0.1:3000,"
Expand Down
53 changes: 53 additions & 0 deletions app/services/discord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import annotations

from datetime import UTC

import httpx

from app.models.waitlist import WaitlistEntry
from app.services.youtube import extract_video_id


def _build_youtube_thumbnail_url(youtube_url: str) -> str | None:
try:
video_id = extract_video_id(youtube_url)
except ValueError:
return None
return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"


def build_waitlist_signup_payload(entry: WaitlistEntry) -> dict:
timestamp = int(entry.created_at.astimezone(UTC).timestamp())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat naive created_at as UTC before converting timestamp

entry.created_at.astimezone(UTC) produces incorrect epoch values when created_at is naive (common with SQLite and some DB/driver timezone settings), because Python assumes the host local timezone for naive datetimes. In non-UTC deployments this shifts the displayed Discord "Signed Up" time by the server offset, so notifications show wrong signup times even though the stored value represents UTC.

Useful? React with 👍 / 👎.

embed = {
"title": f"{entry.name} joined the waitlist",
"description": f"[Open submitted video]({entry.youtube_url})",
"url": entry.youtube_url,
"color": 0x5865F2,
"fields": [
{"name": "Email", "value": entry.email, "inline": True},
{"name": "Source", "value": entry.source, "inline": True},
{
"name": "Signed Up",
"value": f"<t:{timestamp}:F>\n<t:{timestamp}:R>",
"inline": False,
},
],
}

if entry.picture:
embed["thumbnail"] = {"url": entry.picture}

youtube_thumbnail_url = _build_youtube_thumbnail_url(entry.youtube_url)
if youtube_thumbnail_url:
embed["image"] = {"url": youtube_thumbnail_url}

return {"content": None, "embeds": [embed]}


def notify_waitlist_signup(entry: WaitlistEntry, webhook_url: str) -> None:
response = httpx.post(
webhook_url,
json=build_waitlist_signup_payload(entry),
timeout=5.0,
)
response.raise_for_status()
55 changes: 55 additions & 0 deletions tests/test_discord_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from datetime import UTC, datetime

from app.models.waitlist import WaitlistEntry
from app.services.discord import build_waitlist_signup_payload


def test_build_waitlist_signup_payload_includes_user_and_video_context():
entry = WaitlistEntry(
id=1,
user_id=7,
email="waitlist@example.com",
name="Waitlist User",
picture="https://example.com/profile.png",
youtube_url="https://www.youtube.com/watch?v=abc123XYZ00&t=1237s",
source="landing_early_access",
created_at=datetime(2026, 5, 13, 13, 6, 35, tzinfo=UTC),
)

payload = build_waitlist_signup_payload(entry)

assert payload["content"] is None
embed = payload["embeds"][0]
assert embed["title"] == "Waitlist User joined the waitlist"
assert embed["description"] == "[Open submitted video](https://www.youtube.com/watch?v=abc123XYZ00&t=1237s)"
assert embed["url"] == "https://www.youtube.com/watch?v=abc123XYZ00&t=1237s"
assert embed["thumbnail"] == {"url": "https://example.com/profile.png"}
assert embed["image"] == {"url": "https://i.ytimg.com/vi/abc123XYZ00/hqdefault.jpg"}
assert embed["fields"] == [
{"name": "Email", "value": "waitlist@example.com", "inline": True},
{"name": "Source", "value": "landing_early_access", "inline": True},
{
"name": "Signed Up",
"value": "<t:1778677595:F>\n<t:1778677595:R>",
"inline": False,
},
]


def test_build_waitlist_signup_payload_omits_images_when_not_available():
entry = WaitlistEntry(
id=1,
user_id=7,
email="waitlist@example.com",
name="Waitlist User",
picture=None,
youtube_url="https://example.com/not-youtube",
source="landing_early_access",
created_at=datetime(2026, 5, 13, 13, 6, 35, tzinfo=UTC),
)

payload = build_waitlist_signup_payload(entry)

embed = payload["embeds"][0]
assert "thumbnail" not in embed
assert "image" not in embed
123 changes: 123 additions & 0 deletions tests/test_waitlist_api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from collections.abc import Generator
from unittest.mock import Mock

from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.api.auth import get_google_user
from app.core.config import get_settings
from app.db.base import Base
from app.db.session import get_db
from app.main import app
Expand Down Expand Up @@ -82,3 +84,124 @@ def test_create_waitlist_entry_requires_auth():
)

assert response.status_code == 401


def test_create_waitlist_entry_notifies_discord_when_webhook_is_configured(monkeypatch):
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(engine)

def override_db() -> Generator[Session, None, None]:
db = TestingSessionLocal()
try:
yield db
finally:
db.close()

def override_google() -> GoogleUserInfo:
return GoogleUserInfo(
sub="google-waitlist",
email="waitlist@example.com",
name="Waitlist User",
picture="https://example.com/waitlist.png",
)

mock_notify = Mock()
monkeypatch.setattr("app.api.waitlist.notify_waitlist_signup", mock_notify)
settings = get_settings()
original_webhook_url = settings.discord_waitlist_webhook_url
settings.discord_waitlist_webhook_url = "https://discord.com/api/webhooks/test"

app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_google_user] = override_google
try:
client = TestClient(app)
login = client.post("/api/auth/google", json={"id_token": "token"})
token = login.json()["access_token"]

response = client.post(
"/api/waitlist",
json={
"youtubeUrl": "https://www.youtube.com/watch?v=abc123XYZ00",
"source": "landing_early_access",
},
headers={"Authorization": f"Bearer {token}"},
)

assert response.status_code == 200
mock_notify.assert_called_once()
notified_entry = mock_notify.call_args.args[0]
notified_webhook_url = mock_notify.call_args.args[1]
assert notified_entry.email == "waitlist@example.com"
assert notified_entry.youtube_url == "https://www.youtube.com/watch?v=abc123XYZ00"
assert notified_webhook_url == "https://discord.com/api/webhooks/test"
finally:
settings.discord_waitlist_webhook_url = original_webhook_url
app.dependency_overrides.clear()
engine.dispose()


def test_create_waitlist_entry_succeeds_when_discord_notification_fails(monkeypatch):
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(engine)

def override_db() -> Generator[Session, None, None]:
db = TestingSessionLocal()
try:
yield db
finally:
db.close()

def override_google() -> GoogleUserInfo:
return GoogleUserInfo(
sub="google-waitlist",
email="waitlist@example.com",
name="Waitlist User",
picture="https://example.com/waitlist.png",
)

def raise_notification_error(_entry: WaitlistEntry, _webhook_url: str) -> None:
raise RuntimeError("discord unavailable")

monkeypatch.setattr("app.api.waitlist.notify_waitlist_signup", raise_notification_error)
settings = get_settings()
original_webhook_url = settings.discord_waitlist_webhook_url
settings.discord_waitlist_webhook_url = "https://discord.com/api/webhooks/test"

app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_google_user] = override_google
try:
client = TestClient(app)
login = client.post("/api/auth/google", json={"id_token": "token"})
token = login.json()["access_token"]

response = client.post(
"/api/waitlist",
json={
"youtubeUrl": "https://www.youtube.com/watch?v=abc123XYZ00",
"source": "landing_early_access",
},
headers={"Authorization": f"Bearer {token}"},
)

assert response.status_code == 200
db = TestingSessionLocal()
try:
saved = db.scalar(select(WaitlistEntry))
assert saved is not None
assert saved.email == "waitlist@example.com"
finally:
db.close()
finally:
settings.discord_waitlist_webhook_url = original_webhook_url
app.dependency_overrides.clear()
engine.dispose()
Loading