diff --git a/alembic/versions/002_add_gdpr_fields.py b/alembic/versions/002_add_gdpr_fields.py new file mode 100644 index 0000000..113ff1b --- /dev/null +++ b/alembic/versions/002_add_gdpr_fields.py @@ -0,0 +1,25 @@ +"""add consent_given_at and anonymized_at columns + +Revision ID: 002 +Revises: 001 +Create Date: 2026-04-11 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("waitlist_entries", sa.Column("consent_given_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("waitlist_entries", sa.Column("anonymized_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("waitlist_entries", "anonymized_at") + op.drop_column("waitlist_entries", "consent_given_at") diff --git a/app/brevo.py b/app/brevo.py index aa7a57c..306e4b2 100644 --- a/app/brevo.py +++ b/app/brevo.py @@ -24,7 +24,7 @@ def _headers() -> dict[str, str]: } -async def send_confirmation_email(email: str, confirm_url: str) -> bool: +async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url: str = "") -> bool: """Send a double opt-in confirmation email. Returns True on success.""" if not settings.brevo_configured: logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3]) @@ -37,7 +37,7 @@ async def send_confirmation_email(email: str, confirm_url: str) -> bool: }, "to": [{"email": email}], "subject": "Confirm your spot on the adiuvAI waitlist", - "htmlContent": _confirmation_html(confirm_url), + "htmlContent": _confirmation_html(confirm_url, unsubscribe_url), } try: @@ -78,7 +78,7 @@ async def add_contact_to_list(email: str) -> bool: return False -def _confirmation_html(confirm_url: str) -> str: +def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str: """Email template aligned with the adiuvAI landing page brand.""" return f"""\ @@ -211,6 +211,7 @@ def _confirmation_html(confirm_url: str) -> str:
adiuvai.com + {f' · Unsubscribe' if unsubscribe_url else ''}
diff --git a/app/cleanup.py b/app/cleanup.py new file mode 100644 index 0000000..aecdfdd --- /dev/null +++ b/app/cleanup.py @@ -0,0 +1,58 @@ +""" +Periodic cleanup: anonymize unconfirmed waitlist entries older than CONFIRM_TOKEN_EXPIRY_HOURS. + +Run as a cron job: + python -m app.cleanup + +Or integrate into docker-compose with a one-shot service. +""" + +import asyncio +import datetime +import logging + +from sqlalchemy import select, and_ + +from app.config import settings +from app.db import async_session +from app.models import WaitlistEntry +from app.routes import _anonymize_entry + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def anonymize_expired() -> int: + """Anonymize all unconfirmed entries past the token expiry window. Returns count.""" + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + hours=settings.CONFIRM_TOKEN_EXPIRY_HOURS + ) + + async with async_session() as db: + result = await db.execute( + select(WaitlistEntry).where( + and_( + WaitlistEntry.confirmed == False, # noqa: E712 + WaitlistEntry.anonymized_at == None, # noqa: E711 + WaitlistEntry.created_at < cutoff, + ) + ) + ) + entries = result.scalars().all() + + for entry in entries: + _anonymize_entry(entry) + + await db.commit() + + if entries: + logger.info("Anonymized %d expired unconfirmed entries", len(entries)) + else: + logger.info("No expired unconfirmed entries to anonymize") + + return len(entries) + + +if __name__ == "__main__": + count = asyncio.run(anonymize_expired()) + raise SystemExit(0) diff --git a/app/models.py b/app/models.py index 3307f84..1fa71f4 100644 --- a/app/models.py +++ b/app/models.py @@ -21,6 +21,12 @@ class WaitlistEntry(Base): ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) source: Mapped[str | None] = mapped_column(String(64), nullable=True) confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0")) + consent_given_at: Mapped[datetime.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + anonymized_at: Mapped[datetime.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) created_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), diff --git a/app/routes.py b/app/routes.py index f66f0cc..3c98226 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,4 +1,5 @@ import asyncio +import datetime import logging from fastapi import APIRouter, Depends, Request @@ -31,6 +32,7 @@ async def join_waitlist( - Duplicate emails: idempotent — returns success without error. - Stores the Cloudflare-resolved client IP for analytics (not exposed). - Sends a confirmation email via Brevo (fire-and-forget). + - Records consent timestamp (GDPR Art. 7). """ # Honeypot — bots fill hidden fields; silently "succeed" if body.website: @@ -38,6 +40,7 @@ async def join_waitlist( email = body.email.lower().strip() ip = _get_client_ip(request) + now = datetime.datetime.now(datetime.timezone.utc) # Check for existing entry — idempotent existing = await db.execute( @@ -46,14 +49,20 @@ async def join_waitlist( entry = existing.scalar_one_or_none() if entry is not None: - # Re-send confirmation if not yet confirmed - if not entry.confirmed and settings.brevo_configured: + # Re-send confirmation if not yet confirmed (and not anonymized) + if not entry.confirmed and not entry.anonymized_at and settings.brevo_configured: token = generate_token(email) confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}" - asyncio.create_task(send_confirmation_email(email, confirm_url)) + unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}" + asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url)) return WaitlistResponse() - entry = WaitlistEntry(email=email, ip_address=ip, source="website") + entry = WaitlistEntry( + email=email, + ip_address=ip, + source="website", + consent_given_at=now, + ) db.add(entry) await db.commit() @@ -63,7 +72,8 @@ async def join_waitlist( if settings.brevo_configured: token = generate_token(email) confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}" - asyncio.create_task(send_confirmation_email(email, confirm_url)) + unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}" + asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url)) return WaitlistResponse() @@ -92,6 +102,8 @@ async def confirm_email( if not entry.confirmed: entry.confirmed = True + # Clear IP now that consent is confirmed — no longer needed + entry.ip_address = None await db.commit() logger.info("Email confirmed: %s***", email[:3]) @@ -102,9 +114,62 @@ async def confirm_email( return HTMLResponse(content=_result_page(success=True)) -def _result_page(*, success: bool) -> str: - """Branded HTML response for confirmation result, matching the adiuvAI landing page.""" - if success: +@router.get("/waitlist/unsubscribe", response_class=HTMLResponse) +async def unsubscribe( + token: str, + db: AsyncSession = Depends(get_db), +) -> HTMLResponse: + """ + GDPR erasure (Art. 17) — anonymize the entry. + Uses the same HMAC token system as confirmation. + """ + email = verify_token(token) + if email is None: + return HTMLResponse( + content=_result_page(success=False, variant="unsubscribe"), + status_code=400, + ) + + result = await db.execute( + select(WaitlistEntry).where(WaitlistEntry.email == email) + ) + entry = result.scalar_one_or_none() + if entry is None: + # Already gone — show success anyway (idempotent) + return HTMLResponse(content=_result_page(success=True, variant="unsubscribe")) + + _anonymize_entry(entry) + await db.commit() + logger.info("Waitlist entry anonymized (unsubscribe)") + + return HTMLResponse(content=_result_page(success=True, variant="unsubscribe")) + + +def _anonymize_entry(entry: WaitlistEntry) -> None: + """Strip all PII from a waitlist entry, keeping only anonymous analytics.""" + now = datetime.datetime.now(datetime.timezone.utc) + entry.email = f"anon-{entry.id}@removed.invalid" + entry.ip_address = None + entry.consent_given_at = None + entry.anonymized_at = now + + +def _result_page(*, success: bool, variant: str = "confirm") -> str: + """Branded HTML response for confirmation/unsubscribe result.""" + if variant == "unsubscribe": + if success: + title = "Data removed" + message = "Your personal data has been anonymized. You will not receive any further emails from us." + icon_bg = "rgba(251,200,129,0.12)" + icon_border = "rgba(251,200,129,0.25)" + icon_svg = '' + else: + title = "Invalid or expired link" + message = "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help." + icon_bg = "rgba(220,38,38,0.08)" + icon_border = "rgba(220,38,38,0.18)" + icon_svg = '' + elif success: title = "You’re confirmed!" message = "Your email has been verified. We’ll notify you when adiuvAI is ready." icon_bg = "rgba(251,200,129,0.12)" diff --git a/tests/test_waitlist.py b/tests/test_waitlist.py index f0a06a6..847943a 100644 --- a/tests/test_waitlist.py +++ b/tests/test_waitlist.py @@ -153,9 +153,9 @@ async def test_token_tampered(): @pytest.mark.asyncio async def test_confirm_valid_token(client, db_session): - """GET /confirm with valid token marks email as confirmed.""" + """GET /confirm with valid token marks email as confirmed and clears IP.""" # Seed an unconfirmed entry - entry = WaitlistEntry(email="confirm@example.com", source="website") + entry = WaitlistEntry(email="confirm@example.com", source="website", ip_address="1.2.3.4") db_session.add(entry) await db_session.commit() @@ -168,7 +168,9 @@ async def test_confirm_valid_token(client, db_session): result = await db_session.execute( select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com") ) - assert result.scalar_one().confirmed is True + confirmed_entry = result.scalar_one() + assert confirmed_entry.confirmed is True + assert confirmed_entry.ip_address is None # IP cleared on confirm @pytest.mark.asyncio @@ -224,3 +226,64 @@ async def test_signup_triggers_confirmation_email(client, db_session): call_args = mock_send.call_args assert call_args[0][0] == "brevo@example.com" assert "confirm" in call_args[0][1] + + +# ── Unsubscribe / anonymize tests ──────────────────────────────────── + + +@pytest.mark.asyncio +async def test_unsubscribe_anonymizes_entry(client, db_session): + """GET /unsubscribe with valid token anonymizes the entry.""" + entry = WaitlistEntry(email="unsub@example.com", source="website", confirmed=True) + db_session.add(entry) + await db_session.commit() + entry_id = entry.id + + token = generate_token("unsub@example.com") + resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}") + assert resp.status_code == 200 + assert "removed" in resp.text.lower() or "anonymized" in resp.text.lower() + + # Verify anonymization + result = await db_session.execute( + select(WaitlistEntry).where(WaitlistEntry.id == entry_id) + ) + anon = result.scalar_one() + assert anon.email == f"anon-{entry_id}@removed.invalid" + assert anon.ip_address is None + assert anon.consent_given_at is None + assert anon.anonymized_at is not None + + +@pytest.mark.asyncio +async def test_unsubscribe_invalid_token(client): + """GET /unsubscribe with invalid token returns 400.""" + resp = await client.get("/api/v1/waitlist/unsubscribe?token=garbage") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_unsubscribe_already_gone(client): + """GET /unsubscribe for non-existent entry returns 200 (idempotent).""" + token = generate_token("gone@example.com") + resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}") + assert resp.status_code == 200 + + +# ── Consent timestamp tests ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_signup_records_consent_timestamp(client, db_session): + """New signup records consent_given_at.""" + resp = await client.post( + "/api/v1/waitlist", + json={"email": "consent@example.com"}, + ) + assert resp.status_code == 200 + + result = await db_session.execute( + select(WaitlistEntry).where(WaitlistEntry.email == "consent@example.com") + ) + entry = result.scalar_one() + assert entry.consent_given_at is not None