diff --git a/assets/.ipynb_checkpoints/icon-checkpoint.svg b/assets/.ipynb_checkpoints/icon-checkpoint.svg new file mode 100644 index 00000000..21636a4d --- /dev/null +++ b/assets/.ipynb_checkpoints/icon-checkpoint.svg @@ -0,0 +1,14 @@ + + QyverixAI icon + QyverixAI hexagon icon mark with search lens + + + + + + + + + + + diff --git a/assets/.ipynb_checkpoints/logo-dark-checkpoint.svg b/assets/.ipynb_checkpoints/logo-dark-checkpoint.svg new file mode 100644 index 00000000..71c0468a --- /dev/null +++ b/assets/.ipynb_checkpoints/logo-dark-checkpoint.svg @@ -0,0 +1,31 @@ + + QyverixAI + QyverixAI — AI Developer Assistant, dark background version + + + + + + + + + + + + + + + + + + + + + QyverixAI + diff --git a/backend/app/routers/subscribe.py b/backend/app/routers/subscribe.py index 266007a4..5e3362b8 100644 --- a/backend/app/routers/subscribe.py +++ b/backend/app/routers/subscribe.py @@ -6,9 +6,9 @@ from sqlalchemy.orm import Session from ..database import get_db +from ..models import DigestSubscription from ..schemas import SubscribeRequest, SubscribeResponse, UnsubscribeRequest from ..services.email_service import _generate_token -from ..models import DigestSubscription router = APIRouter(tags=["subscribe"]) @@ -17,6 +17,35 @@ def subscribe(body: SubscribeRequest, db: Session = Depends(get_db)): """Subscribe an email address to the weekly digest. + Endpoint: POST /subscribe/ + + Request Body: + email (str): The email address to subscribe. + + Example Request: + POST /subscribe/ + { + "email": "user@example.com" + } + + Example Response (200 OK): + { + "message": "You're subscribed! You'll receive your first digest next Sunday.", + "email": "user@example.com" + } + + Example Response (409 Conflict - already subscribed): + { + "detail": "This email is already subscribed to the weekly digest." + } + + Subscription Lifecycle: + - New email -> creates an active subscription record. + - Previously unsubscribed email -> re-activates the existing + record and issues a new unsubscribe_token (old token becomes + invalid for security reasons). + - Already active email -> returns 409 Conflict. + If the email was previously subscribed but unsubscribed, this re-activates the subscription rather than creating a duplicate. """ @@ -57,7 +86,37 @@ def subscribe(body: SubscribeRequest, db: Session = Depends(get_db)): def unsubscribe(body: UnsubscribeRequest, db: Session = Depends(get_db)): """Unsubscribe an email address from the weekly digest. + Endpoint: POST /subscribe/unsubscribe + + Request Body: + email (str): The subscribed email address. + token (str): The unsubscribe_token issued at subscription time. + + Example Request: + POST /subscribe/unsubscribe + { + "email": "user@example.com", + "token": "abc123" + } + + Example Response (200 OK): + { + "message": "You've been unsubscribed from the weekly digest.", + "email": "user@example.com" + } + + Example Response (404 Not Found): + { + "detail": "Subscription not found or already inactive." + } + + Example Response (403 Forbidden - wrong token): + { + "detail": "Invalid unsubscribe token." + } + Requires both the email and its unsubscribe token for verification. + This prevents anyone from unsubscribing an email they don't own. """ email = body.email.strip().lower() @@ -92,7 +151,43 @@ def unsubscribe_via_get( token: str = Query(...), db: Session = Depends(get_db), ): - """GET-based unsubscribe for one-click links in email.""" + """GET-based unsubscribe for one-click links in email. + + Endpoint: GET /subscribe/unsubscribe + + Query Parameters: + email (str): The subscribed email address. + token (str): The unsubscribe_token issued at subscription time. + + Example Request: + GET /subscribe/unsubscribe?email=user@example.com&token=abc123 + + Example Response (200 OK): + { + "message": "You've been unsubscribed from the weekly digest." + } + + Example Response (already inactive): + { + "message": "Subscription not found or already inactive." + } + + Example Response (invalid token): + { + "message": "Invalid unsubscribe link." + } + + Why This Endpoint Exists (Webhook/Email Callback Use Case): + Email clients allow one-click unsubscribe links to be plain + GET requests (no JSON body needed). This makes the endpoint + usable directly inside an email template, e.g.: + + https://yourapp.com/subscribe/unsubscribe?email={{email}}&token={{token}} + + This is the same pattern used for webhook-style callbacks + where an external service (like an email provider) needs to + hit a URL directly without constructing a POST request body. + """ sub = ( db.query(DigestSubscription) .filter( diff --git a/backend/tests/test_error_tracking.py b/backend/tests/test_error_tracking.py new file mode 100644 index 00000000..390550f1 --- /dev/null +++ b/backend/tests/test_error_tracking.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +from app.services.error_tracking import init_error_tracking + + +def test_error_tracking_disabled_when_no_dsn(): + """Verify init_error_tracking returns False when sentry_dsn is not set.""" + with patch("app.services.error_tracking.settings") as mock_settings: + mock_settings.sentry_dsn = None + + result = init_error_tracking() + + assert result is False + + +def test_error_tracking_enabled_when_dsn_present(): + """Verify init_error_tracking returns True when sentry_dsn is set.""" + with patch("app.services.error_tracking.settings") as mock_settings: + mock_settings.sentry_dsn = "https://fake@sentry.io/123" + mock_settings.sentry_traces_sample_rate = 1.0 + + with patch("sentry_sdk.init") as mock_sentry_init: + mock_sentry_init.return_value = None + + result = init_error_tracking() + + assert result is True + + +def test_error_tracking_returns_false_on_init_failure(): + """Verify init_error_tracking returns False when sentry init raises exception.""" + with patch("app.services.error_tracking.settings") as mock_settings: + mock_settings.sentry_dsn = "https://fake@sentry.io/123" + mock_settings.sentry_traces_sample_rate = 1.0 + + with patch("sentry_sdk.init", side_effect=Exception("Sentry failed")): + + result = init_error_tracking() + + assert result is False \ No newline at end of file diff --git a/docs/SUBSCRIPTION_GUIDE.md b/docs/SUBSCRIPTION_GUIDE.md new file mode 100644 index 00000000..078be6c8 --- /dev/null +++ b/docs/SUBSCRIPTION_GUIDE.md @@ -0,0 +1,119 @@ +# Subscription Integration Guide + +This guide walks through the complete end-to-end flow for integrating +with the weekly digest subscription API. + +--- + +## End-to-End Flow + +### Step 1: Subscribe + +Send a POST request to subscribe an email address: + + POST /subscribe/ + Content-Type: application/json + + { + "email": "user@example.com" + } + +Response (200 OK): + + { + "message": "You're subscribed! You'll receive your first digest next Sunday.", + "email": "user@example.com" + } + +Response (409 Conflict - already subscribed): + + { + "detail": "This email is already subscribed to the weekly digest." + } + +--- + +### Step 2: Email/Webhook Delivery + +Once subscribed, the system sends a weekly digest email every Sunday. +The email contains an unsubscribe link: + + https://yourapp.com/subscribe/unsubscribe?email=user@example.com&token=abc123 + +The token is a unique unsubscribe_token generated at subscription time. + +Webhook Callback Testing (Local Development): + + pip install ngrok + ngrok http 8000 + +Use the generated URL for webhook testing: + + https://abc123.ngrok.io/subscribe/unsubscribe?email=user@example.com&token=abc123 + +--- + +### Step 3: Unsubscribe + +Option A - One-Click Link (GET): +User clicks the unsubscribe link in the email: + + GET /subscribe/unsubscribe?email=user@example.com&token=abc123 + +Response (200 OK): + + { + "message": "You've been unsubscribed from the weekly digest." + } + +Option B - API Call (POST): + + POST /subscribe/unsubscribe + Content-Type: application/json + + { + "email": "user@example.com", + "token": "abc123" + } + +Response (200 OK): + + { + "message": "You've been unsubscribed from the weekly digest.", + "email": "user@example.com" + } + +--- + +## Subscription Lifecycle Summary + + New Email + | + v + POST /subscribe/ -> Active Subscription Created + | + v + Weekly Digest Email Sent (every Sunday) + | + v + User Clicks Unsubscribe Link + | + v + GET /subscribe/unsubscribe -> Subscription Deactivated + | + v + Re-subscribe Anytime + | + v + POST /subscribe/ -> Subscription Re-activated (new token issued) + +--- + +## Error Reference + +| Status Code | Meaning | +|-------------|------------------------------| +| 200 | Success | +| 409 | Email already subscribed | +| 404 | Subscription not found | +| 403 | Invalid unsubscribe token | \ No newline at end of file