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
99 changes: 97 additions & 2 deletions modal_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import modal

app = modal.App("state-research-tracker")
RUNTIME_SECRET_NAME = "state-research-tracker-runtime"

REPO_URL = "https://github.com/PolicyEngine/state-legislative-tracker.git"
BRANCH = "main"
Expand Down Expand Up @@ -51,20 +52,22 @@
f"cd /app && SUPABASE_ANON_KEY={SUPABASE_ANON_KEY}"
" node scripts/prerender.mjs",
)
.pip_install("fastapi", "uvicorn", "aiofiles", "httpx")
.pip_install("fastapi", "uvicorn", "aiofiles", "httpx", "resend")
)


@app.function(
image=image,
allow_concurrent_inputs=100,
secrets=[modal.Secret.from_name(RUNTIME_SECRET_NAME)],
)
@modal.asgi_app(label="state-legislative-tracker")
def web():
"""Serve static files with FastAPI."""
from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
import httpx
import json
import os
Expand All @@ -86,6 +89,76 @@ def web():
POSTHOG_HOST = "https://us.i.posthog.com"
POSTHOG_ASSETS_HOST = "https://us-assets.i.posthog.com"

class BillAnalysisRequest(BaseModel):
state: str
bill_number: str
title: str
bill_url: str
requester_email: str
subscribe_newsletter: bool = False
request_source: str | None = None

async def store_request(payload: BillAnalysisRequest, request: Request):
supabase_url = os.environ.get("SUPABASE_URL")
supabase_key = os.environ.get("SUPABASE_KEY")
if not supabase_url or not supabase_key:
return False

row = {
"state": payload.state,
"bill_number": payload.bill_number,
"title": payload.title,
"bill_url": payload.bill_url,
"requester_email": payload.requester_email,
"subscribe_newsletter": payload.subscribe_newsletter,
"request_source": payload.request_source,
"origin": str(request.base_url).rstrip("/"),
"user_agent": request.headers.get("user-agent"),
}

response = await http_client.post(
f"{supabase_url}/rest/v1/bill_analysis_requests",
headers={
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json=row,
)
response.raise_for_status()
return True

def send_notification_email(payload: BillAnalysisRequest):
import resend

api_key = os.environ.get("RESEND_API_KEY")
if not api_key:
return False

resend.api_key = api_key
smtp_to = os.environ.get(
"BILL_REQUEST_NOTIFICATION_TO",
"hello@policyengine.org,pavel@policyengine.org",
)
recipients = [email.strip() for email in smtp_to.split(",") if email.strip()]

resend.Emails.send({
"from": "PolicyEngine Team <hello@policyengine.org>",
"to": recipients,
"subject": f"Score bill request: {payload.state} {payload.bill_number}",
"html": (
f"<p>A new bill analysis request was submitted.</p>"
f"<p><strong>Requester:</strong> {payload.requester_email}</p>"
f"<p><strong>Newsletter opt-in:</strong> {'yes' if payload.subscribe_newsletter else 'no'}</p>"
f"<p><strong>Source:</strong> {payload.request_source or 'unknown'}</p>"
f"<p><strong>Bill:</strong> {payload.state} {payload.bill_number}</p>"
f"<p><strong>Title:</strong> {payload.title}</p>"
f"<p><strong>Link:</strong> <a href='{payload.bill_url}'>{payload.bill_url}</a></p>"
),
})
return True

@api.api_route("/ingest/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def posthog_proxy(path: str, request: Request):
# Static assets come from a different host
Expand Down Expand Up @@ -120,6 +193,28 @@ async def posthog_proxy(path: str, request: Request):
headers=resp_headers,
)

@api.post("/api/bill-analysis-request")
async def bill_analysis_request(payload: BillAnalysisRequest, request: Request):
if "@" not in payload.requester_email or "." not in payload.requester_email.split("@")[-1]:
raise HTTPException(status_code=400, detail="Please enter a valid email address.")

result = {
"stored": False,
"notification_sent": False,
}

try:
result["stored"] = await store_request(payload, request)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not store request: {exc}") from exc

try:
result["notification_sent"] = send_notification_email(payload)
except Exception:
result["notification_sent"] = False

return result

@api.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve the SPA with proper 404s for invalid routes."""
Expand Down
36 changes: 36 additions & 0 deletions scripts/sql/007_add_bill_analysis_requests.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- ============================================================================
-- TABLE: bill_analysis_requests
-- Stores requests from users asking PolicyEngine to analyze an unmodeled bill.
-- This acts as the canonical export source for CSV downloads / back-office review.
-- ============================================================================

CREATE TABLE IF NOT EXISTS bill_analysis_requests (
id BIGSERIAL PRIMARY KEY,
state TEXT NOT NULL,
bill_number TEXT NOT NULL,
title TEXT NOT NULL,
bill_url TEXT NOT NULL,
requester_email TEXT NOT NULL,
subscribe_newsletter BOOLEAN NOT NULL DEFAULT FALSE,
request_source TEXT,
origin TEXT,
user_agent TEXT,
handled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

COMMENT ON TABLE bill_analysis_requests IS 'Inbound requests for new bill analysis from the public tracker UI';
COMMENT ON COLUMN bill_analysis_requests.bill_url IS 'Official or source URL for the requested bill';
COMMENT ON COLUMN bill_analysis_requests.subscribe_newsletter IS 'Whether the requester opted into the newsletter at submission time';
COMMENT ON COLUMN bill_analysis_requests.request_source IS 'UI source identifier, e.g. recent_activity_all_bills';

CREATE INDEX IF NOT EXISTS idx_bill_analysis_requests_created_at
ON bill_analysis_requests(created_at DESC);

CREATE INDEX IF NOT EXISTS idx_bill_analysis_requests_handled
ON bill_analysis_requests(handled);

ALTER TABLE bill_analysis_requests ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Service write bill analysis requests" ON bill_analysis_requests
FOR ALL USING (auth.role() = 'service_role');
Loading