Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
624bfb4
docs: add subscription router examples and webhook testing tips
mpu3450-spec Jun 6, 2026
b4cc2b2
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 8, 2026
4a9e767
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 12, 2026
9e1975e
fix: format subscribe.py with black formatter
mpu3450-spec Jun 14, 2026
f248f64
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 14, 2026
5432e65
fix: resolve flake8 lint errors in subscribe.py
mpu3450-spec Jun 17, 2026
79d7422
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 17, 2026
da56368
fix: sort imports with isort
mpu3450-spec Jun 17, 2026
f66333d
merge: sync with main branch
mpu3450-spec Jun 17, 2026
94550d3
docs: add detailed endpoint documentation with examples and webhook l…
mpu3450-spec Jun 18, 2026
0eaba55
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 18, 2026
2ff15b5
docs: add end-to-end subscription integration guide
mpu3450-spec Jun 19, 2026
69d9e52
docs: add end-to-end subscription integration guide
mpu3450-spec Jun 20, 2026
955e489
Merge branch 'main' into docs/subscribe-router-examples
mpu3450-spec Jun 20, 2026
f54966d
revert: restore pull_request_template.md to original
mpu3450-spec Jun 23, 2026
6fdfe96
Merge branch 'main' into docs/subscribe-router-examples
imDarshanGK Jun 23, 2026
aa227ca
text error tracking file added
mpu3450-spec Jul 27, 2026
74fc708
test error tracking file added
mpu3450-spec Jul 27, 2026
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
14 changes: 14 additions & 0 deletions assets/.ipynb_checkpoints/icon-checkpoint.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions assets/.ipynb_checkpoints/logo-dark-checkpoint.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
99 changes: 97 additions & 2 deletions backend/app/routers/subscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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.
"""
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/test_error_tracking.py
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions docs/SUBSCRIPTION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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 |
Loading