diff --git a/.env.example b/.env.example index 365cc45..51780fa 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ YOUTUBE_API_KEY= AI_PROVIDER= GEMINI_API_KEY= GEMINI_MODEL= -SUPADATA_API_KEY= -CORS_ORIGINS= \ No newline at end of file +CORS_ORIGINS= + +SUPADATA_API_KEY= +DISCORD_WAITLIST_WEBHOOK_URL= +DISCORD_NEW_USER_WEBHOOK_URL= diff --git a/README.md b/README.md index 380a78b..1ac17c7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ 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. +Set `DISCORD_WAITLIST_WEBHOOK_URL` for waitlist notifications and `DISCORD_NEW_USER_WEBHOOK_URL` +for first-time Google signup notifications if you want them posted to Discord. Run database migrations: diff --git a/app/api/auth.py b/app/api/auth.py index ed51a28..3a0d699 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,14 +1,19 @@ +import logging + from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.config import get_settings from app.core.security import create_access_token from app.db.session import get_db from app.models.user import User from app.schemas.auth import GoogleLoginRequest, TokenResponse +from app.services.discord import notify_new_user_signup from app.services.google_auth import GoogleUserInfo, verify_google_id_token router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger(__name__) def get_google_user(request: GoogleLoginRequest) -> GoogleUserInfo: @@ -21,8 +26,9 @@ def google_login( google_user: GoogleUserInfo = Depends(get_google_user), ) -> TokenResponse: user = db.scalar(select(User).where(User.google_sub == google_user.sub)) + is_new_user = user is None - if user is None: + if is_new_user: user = User( google_sub=google_user.sub, email=google_user.email, @@ -38,6 +44,13 @@ def google_login( db.commit() db.refresh(user) + webhook_url = get_settings().discord_new_user_webhook_url + if is_new_user and webhook_url: + try: + notify_new_user_signup(user, webhook_url) + except Exception: + logger.exception("Failed to notify Discord for new Google login", extra={"user_id": user.id}) + return TokenResponse( access_token=create_access_token(subject=str(user.id)), user=user, diff --git a/app/core/config.py b/app/core/config.py index 7f61907..c4faae5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): gemini_model: str = "gemini-2.5-flash" supadata_api_key: str = "" discord_waitlist_webhook_url: str = "" + discord_new_user_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 index 0e588da..a165065 100644 --- a/app/services/discord.py +++ b/app/services/discord.py @@ -4,6 +4,7 @@ import httpx +from app.models.user import User from app.models.waitlist import WaitlistEntry from app.services.youtube import extract_video_id @@ -51,3 +52,29 @@ def notify_waitlist_signup(entry: WaitlistEntry, webhook_url: str) -> None: timeout=5.0, ) response.raise_for_status() + + +def build_new_user_signup_payload(user: User) -> dict: + embed = { + "title": f"{user.name} signed in with Google", + "description": "New user account created", + "color": 0x34A853, + "fields": [ + {"name": "Name", "value": user.name, "inline": True}, + {"name": "Email", "value": user.email, "inline": True}, + ], + } + + if user.picture: + embed["thumbnail"] = {"url": user.picture} + + return {"content": None, "embeds": [embed]} + + +def notify_new_user_signup(user: User, webhook_url: str) -> None: + response = httpx.post( + webhook_url, + json=build_new_user_signup_payload(user), + timeout=5.0, + ) + response.raise_for_status() diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py index 6a3d65d..3a1f5e9 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth_api.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from unittest.mock import Mock import pytest from fastapi.testclient import TestClient @@ -7,6 +8,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 @@ -87,3 +89,45 @@ def test_me_rejects_malformed_bearer_token(client: TestClient): assert response.status_code == 401 assert response.json()["detail"]["code"] == "invalid_token" + + +def test_google_login_notifies_discord_for_new_users_only(client: TestClient, monkeypatch): + mock_notify = Mock() + monkeypatch.setattr("app.api.auth.notify_new_user_signup", mock_notify) + settings = get_settings() + original_webhook_url = settings.discord_new_user_webhook_url + settings.discord_new_user_webhook_url = "https://discord.com/api/webhooks/test" + + try: + first_response = client.post("/api/auth/google", json={"id_token": "valid-google-token"}) + second_response = client.post("/api/auth/google", json={"id_token": "valid-google-token"}) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + mock_notify.assert_called_once() + notified_user = mock_notify.call_args.args[0] + notified_webhook_url = mock_notify.call_args.args[1] + assert notified_user.email == "person@example.com" + assert notified_webhook_url == "https://discord.com/api/webhooks/test" + finally: + settings.discord_new_user_webhook_url = original_webhook_url + + +def test_google_login_succeeds_when_new_user_discord_notification_fails( + client: TestClient, monkeypatch +): + def raise_notification_error(*_args) -> None: + raise RuntimeError("discord unavailable") + + monkeypatch.setattr("app.api.auth.notify_new_user_signup", raise_notification_error) + settings = get_settings() + original_webhook_url = settings.discord_new_user_webhook_url + settings.discord_new_user_webhook_url = "https://discord.com/api/webhooks/test" + + try: + response = client.post("/api/auth/google", json={"id_token": "valid-google-token"}) + + assert response.status_code == 200 + assert response.json()["user"]["email"] == "person@example.com" + finally: + settings.discord_new_user_webhook_url = original_webhook_url diff --git a/tests/test_discord_service.py b/tests/test_discord_service.py index ec6c850..069cfa0 100644 --- a/tests/test_discord_service.py +++ b/tests/test_discord_service.py @@ -1,7 +1,8 @@ from datetime import UTC, datetime +from app.models.user import User from app.models.waitlist import WaitlistEntry -from app.services.discord import build_waitlist_signup_payload +from app.services.discord import build_new_user_signup_payload, build_waitlist_signup_payload def test_build_waitlist_signup_payload_includes_user_and_video_context(): @@ -53,3 +54,25 @@ def test_build_waitlist_signup_payload_omits_images_when_not_available(): embed = payload["embeds"][0] assert "thumbnail" not in embed assert "image" not in embed + + +def test_build_new_user_signup_payload_includes_basic_profile_details(): + user = User( + id=7, + google_sub="google-123", + email="person@example.com", + name="Person", + picture="https://example.com/pic.png", + ) + + payload = build_new_user_signup_payload(user) + + assert payload["content"] is None + embed = payload["embeds"][0] + assert embed["title"] == "Person signed in with Google" + assert embed["description"] == "New user account created" + assert embed["thumbnail"] == {"url": "https://example.com/pic.png"} + assert embed["fields"] == [ + {"name": "Name", "value": "Person", "inline": True}, + {"name": "Email", "value": "person@example.com", "inline": True}, + ]