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 @@
+
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 @@
+
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