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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ YOUTUBE_API_KEY=
AI_PROVIDER=
GEMINI_API_KEY=
GEMINI_MODEL=
SUPADATA_API_KEY=

CORS_ORIGINS=
CORS_ORIGINS=

SUPADATA_API_KEY=
DISCORD_WAITLIST_WEBHOOK_URL=
DISCORD_NEW_USER_WEBHOOK_URL=
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 14 additions & 1 deletion app/api/auth.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,"
Expand Down
27 changes: 27 additions & 0 deletions app/services/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
44 changes: 44 additions & 0 deletions tests/test_auth_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Generator
from unittest.mock import Mock

import pytest
from fastapi.testclient import TestClient
Expand All @@ -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
Expand Down Expand Up @@ -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
25 changes: 24 additions & 1 deletion tests/test_discord_service.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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},
]
Loading