diff --git a/README.md b/README.md index b5bde54..380a78b 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/api/waitlist.py b/app/api/waitlist.py index b8f3df5..3e897ce 100644 --- a/app/api/waitlist.py +++ b/app/api/waitlist.py @@ -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) @@ -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 diff --git a/app/core/config.py b/app/core/config.py index 7412aa0..7f61907 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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," diff --git a/app/services/discord.py b/app/services/discord.py new file mode 100644 index 0000000..0e588da --- /dev/null +++ b/app/services/discord.py @@ -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()) + 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"\n", + "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() diff --git a/tests/test_discord_service.py b/tests/test_discord_service.py new file mode 100644 index 0000000..ec6c850 --- /dev/null +++ b/tests/test_discord_service.py @@ -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": "\n", + "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 diff --git a/tests/test_waitlist_api.py b/tests/test_waitlist_api.py index b3fc233..de3ff87 100644 --- a/tests/test_waitlist_api.py +++ b/tests/test_waitlist_api.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from unittest.mock import Mock from fastapi.testclient import TestClient from sqlalchemy import create_engine, select @@ -6,6 +7,7 @@ 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 @@ -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()