diff --git a/modal_app.py b/modal_app.py index 2160cdc..cb51a55 100644 --- a/modal_app.py +++ b/modal_app.py @@ -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" @@ -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 @@ -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 ", + "to": recipients, + "subject": f"Score bill request: {payload.state} {payload.bill_number}", + "html": ( + f"

A new bill analysis request was submitted.

" + f"

Requester: {payload.requester_email}

" + f"

Newsletter opt-in: {'yes' if payload.subscribe_newsletter else 'no'}

" + f"

Source: {payload.request_source or 'unknown'}

" + f"

Bill: {payload.state} {payload.bill_number}

" + f"

Title: {payload.title}

" + f"

Link: {payload.bill_url}

" + ), + }) + 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 @@ -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.""" diff --git a/scripts/sql/007_add_bill_analysis_requests.sql b/scripts/sql/007_add_bill_analysis_requests.sql new file mode 100644 index 0000000..02ec9a7 --- /dev/null +++ b/scripts/sql/007_add_bill_analysis_requests.sql @@ -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'); diff --git a/src/components/BillActivityFeed.jsx b/src/components/BillActivityFeed.jsx index f736e7b..bab2444 100644 --- a/src/components/BillActivityFeed.jsx +++ b/src/components/BillActivityFeed.jsx @@ -3,19 +3,52 @@ import { supabase } from "../lib/supabase"; import { useData } from "../context/DataContext"; import { colors, typography, spacing } from "../designTokens"; +const REQUEST_API_PATH = "/api/bill-analysis-request"; +const MAILCHIMP_SUBSCRIBE_URL = + "https://policyengine.us5.list-manage.com/subscribe/post-json?u=e5ad35332666289a0f48013c5&id=71ed1f89d8&f_id=00f173e6f0"; + +function subscribeToMailchimp(email) { + return new Promise((resolve, reject) => { + const callbackName = `mailchimpCallback_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + const script = document.createElement("script"); + const cleanup = () => { + delete window[callbackName]; + script.remove(); + }; + + window[callbackName] = (data) => { + cleanup(); + if (data?.result !== "error") { + resolve({ isSuccessful: true, message: data?.msg || "Subscribed." }); + return; + } + resolve({ isSuccessful: false, message: data?.msg || "Subscription failed." }); + }; + + script.onerror = () => { + cleanup(); + reject(new Error("There was an issue processing your subscription; please try again later.")); + }; + + const encodedEmail = encodeURIComponent(email); + script.src = `${MAILCHIMP_SUBSCRIBE_URL}&EMAIL=${encodedEmail}&c=${callbackName}`; + document.body.appendChild(script); + }); +} + // ============== Shared UI ============== const STAGE_COLORS = { "Introduced": { bg: colors.gray[100], text: colors.gray[700], dot: colors.gray[400] }, - "In Committee": { bg: "#FEF3C7", text: "#92400E", dot: "#F59E0B" }, - "Passed Committee": { bg: "#DBEAFE", text: "#1E40AF", dot: "#3B82F6" }, - "First Reading": { bg: "#FEF3C7", text: "#92400E", dot: "#F59E0B" }, - "Second Reading": { bg: "#FEF3C7", text: "#92400E", dot: "#F59E0B" }, - "Third Reading": { bg: "#DBEAFE", text: "#1E40AF", dot: "#3B82F6" }, - "Passed One Chamber": { bg: "#D1FAE5", text: "#065F46", dot: "#10B981" }, - "Passed Both Chambers": { bg: "#A7F3D0", text: "#065F46", dot: "#059669" }, - "Sent to Governor": { bg: "#C7D2FE", text: "#3730A3", dot: "#6366F1" }, - "Signed into Law": { bg: "#D1FAE5", text: "#065F46", dot: "#059669" }, + "In Committee": { bg: colors.primary[50], text: colors.primary[700], dot: colors.primary[400] }, + "Passed Committee": { bg: colors.primary[50], text: colors.primary[700], dot: colors.primary[500] }, + "First Reading": { bg: colors.primary[50], text: colors.primary[700], dot: colors.primary[400] }, + "Second Reading": { bg: colors.primary[50], text: colors.primary[700], dot: colors.primary[500] }, + "Third Reading": { bg: colors.primary[100], text: colors.primary[700], dot: colors.primary[500] }, + "Passed One Chamber": { bg: colors.primary[100], text: colors.primary[800], dot: colors.primary[600] }, + "Passed Both Chambers": { bg: colors.primary[200], text: colors.primary[800], dot: colors.primary[600] }, + "Sent to Governor": { bg: colors.primary[100], text: colors.primary[800], dot: colors.primary[700] }, + "Signed into Law": { bg: colors.primary[200], text: colors.primary[900], dot: colors.primary[700] }, "Vetoed": { bg: "#FEE2E2", text: "#991B1B", dot: "#EF4444" }, "Dead/Withdrawn": { bg: colors.gray[100], text: colors.gray[500], dot: colors.gray[400] }, }; @@ -112,7 +145,9 @@ export function RecentActivitySidebar({ onStateSelect, onBillSelect }) { const { bills, loading } = useProcessedBills(null); const { statesWithBills, research } = useData(); const encodedStates = useMemo(() => new Set(Object.keys(statesWithBills)), [statesWithBills]); - const [tab, setTab] = useState("recent"); // "recent" | "analyzed" + const [tab, setTab] = useState("analyzed"); // "recent" | "analyzed" + const [actionBill, setActionBill] = useState(null); + const [requestBill, setRequestBill] = useState(null); const recentBills = useMemo(() => bills.slice(0, 25), [bills]); @@ -168,93 +203,95 @@ export function RecentActivitySidebar({ onStateSelect, onBillSelect }) { if (!recentBills.length) return null; return ( -
- {/* Header */} + <>
+ {/* Header */} +

- - - Recent Legislative Activity

- {/* Tab toggle */} -
- {[ - { id: "recent", label: "All Bills" }, - { id: "analyzed", label: "Analyzed" }, - ].map((t) => ( - + ))} +
+
+ + {/* List */} +
+ {displayBills.map((bill, i) => { + const normKey = `${bill.state}:${bill.bill_number.replace(/\s+/g, "").replace(/^([A-Z]+)0+(\d)/, "$1$2").toUpperCase()}`; + const match = billToResearchId[normKey]; + const isClickable = !!match && !!onBillSelect; + const showBillActions = tab === "recent"; + + return ( +
{ + if (showBillActions && !isClickable) { + setActionBill({ ...bill, isAnalyzed: isClickable, analysisMatch: match || null }); + return; + } + if (isClickable) onBillSelect(match.state, match.researchId); + }} style={{ - flex: 1, - padding: `3px ${spacing.sm}`, - border: "none", - borderRadius: spacing.radius.sm, - backgroundColor: tab === t.id ? colors.white : "transparent", - boxShadow: tab === t.id ? "0 1px 2px rgba(0,0,0,0.1)" : "none", - color: tab === t.id ? colors.secondary[900] : colors.text.tertiary, - fontSize: "11px", - fontWeight: typography.fontWeight.medium, - fontFamily: typography.fontFamily.body, - cursor: "pointer", + display: "flex", + alignItems: "flex-start", + gap: spacing.sm, + padding: `${spacing.sm} ${spacing.md}`, + borderBottom: `1px solid ${colors.border.light}`, + transition: "background-color 0.1s", + cursor: showBillActions || isClickable ? "pointer" : "default", }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.gray[50]} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "transparent"} > - {t.label} - - ))} -
-
- - {/* List */} -
- {displayBills.map((bill, i) => { - const normKey = `${bill.state}:${bill.bill_number.replace(/\s+/g, "").replace(/^([A-Z]+)0+(\d)/, "$1$2").toUpperCase()}`; - const match = billToResearchId[normKey]; - const isClickable = !!match && !!onBillSelect; - - return ( -
onBillSelect(match.state, match.researchId) : undefined} - style={{ - display: "flex", - alignItems: "flex-start", - gap: spacing.sm, - padding: `${spacing.sm} ${spacing.md}`, - borderBottom: `1px solid ${colors.border.light}`, - transition: "background-color 0.1s", - cursor: isClickable ? "pointer" : "default", - }} - onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.gray[50]} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "transparent"} - > {/* State chip */} + ) : isClickable ? (
- ); - })} + ); + })} +
+
+ {actionBill && ( + setActionBill(null)} + onViewAnalysis={() => { + if (actionBill.analysisMatch && onBillSelect) { + onBillSelect(actionBill.analysisMatch.state, actionBill.analysisMatch.researchId); + } + setActionBill(null); + }} + onRequestAnalysis={() => { + setRequestBill(actionBill); + setActionBill(null); + }} + /> + )} + {requestBill && ( + setRequestBill(null)} + /> + )} + + ); +} + +function BillActionModal({ bill, onClose, onViewAnalysis, onRequestAnalysis }) { + return ( + +

+ {bill.title} +

+
+ + View Bill Text + + {bill.isAnalyzed ? ( + + ) : ( + + )} +
+
+ ); +} + +function AnalysisRequestModal({ bill, onClose }) { + const [email, setEmail] = useState(""); + const [subscribeNewsletter, setSubscribeNewsletter] = useState(true); + const [status, setStatus] = useState({ type: "idle", message: "" }); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitting(true); + setStatus({ type: "idle", message: "" }); + + try { + let newsletterMessage = ""; + if (subscribeNewsletter) { + const newsletterResult = await subscribeToMailchimp(email); + if (!newsletterResult.isSuccessful && !/is already subscribed/i.test(newsletterResult.message)) { + throw new Error(newsletterResult.message); + } + newsletterMessage = newsletterResult.message; + } + + const response = await fetch(REQUEST_API_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + state: bill.state, + bill_number: bill.bill_number, + title: bill.title, + bill_url: bill.legiscan_url, + requester_email: email, + subscribe_newsletter: subscribeNewsletter, + request_source: "recent_activity_all_bills", + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.detail || "Could not submit request."); + } + + setStatus({ + type: "success", + message: "Request received. We’ll notify you when analysis is available for this bill.", + }); + } catch (error) { + setStatus({ + type: "error", + message: error.message || "Could not submit request.", + }); + } finally { + setSubmitting(false); + } + }; + + return ( + +

+ Enter your email and we’ll log the request for scoring. If you opt in, we’ll also add you to the newsletter. +

+
+ + + {status.message && ( +
+ {status.message} +
+ )} +
+ + +
+
+
+ ); +} + +function ModalFrame({ title, children, onClose }) { + return ( +
+
+
+

+ {title} +

+ +
+ {children}
); } +const fieldLabelStyle = { + color: colors.text.secondary, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + fontFamily: typography.fontFamily.body, +}; + +const fieldInputStyle = { + width: "100%", + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.body, + color: colors.secondary[900], + outline: "none", +}; + +const primaryActionStyle = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: spacing.radius.lg, + border: "none", + backgroundColor: colors.primary[600], + color: colors.white, + textDecoration: "none", + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, + fontFamily: typography.fontFamily.body, + cursor: "pointer", +}; + +const secondaryActionStyle = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + color: colors.secondary[900], + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, + fontFamily: typography.fontFamily.body, + cursor: "pointer", +}; + +const tertiaryActionStyle = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: spacing.radius.lg, + border: "none", + backgroundColor: "transparent", + color: colors.text.secondary, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.medium, + fontFamily: typography.fontFamily.body, + cursor: "pointer", +}; + +const closeButtonStyle = { + border: "none", + background: "transparent", + color: colors.text.tertiary, + fontSize: "24px", + lineHeight: 1, + cursor: "pointer", + padding: 0, + flexShrink: 0, +}; + // ============== StateBillActivity (State page section) ============== const STAGE_ORDER_LIST = [ diff --git a/vite.config.js b/vite.config.js index a7dacc9..92f18a8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,18 +1,127 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +function createLocalBillRequestPlugin(env) { + return { + name: 'local-bill-request-api', + configureServer(server) { + server.middlewares.use('/api/bill-analysis-request', async (req, res, next) => { + if (req.method !== 'POST') { + next() + return + } + + const supabaseUrl = env.SUPABASE_URL + const supabaseKey = env.SUPABASE_KEY + if (!supabaseUrl || !supabaseKey) { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ detail: 'Local request handling is not configured.' })) + return + } + + try { + const body = await new Promise((resolve, reject) => { + let raw = '' + req.on('data', (chunk) => { + raw += chunk + }) + req.on('end', () => resolve(raw)) + req.on('error', reject) + }) + + const payload = JSON.parse(body) + const response = await fetch(`${supabaseUrl}/rest/v1/bill_analysis_requests`, { + method: 'POST', + headers: { + apikey: supabaseKey, + Authorization: `Bearer ${supabaseKey}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + 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: 'http://127.0.0.1:4176', + user_agent: req.headers['user-agent'] || null, + }), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(text || 'Could not store request.') + } + + let notificationSent = false + const resendApiKey = env.RESEND_API_KEY + if (resendApiKey) { + const notificationTo = env.BILL_REQUEST_NOTIFICATION_TO || 'hello@policyengine.org,pavel@policyengine.org' + const recipients = notificationTo.split(',').map((email) => email.trim()).filter(Boolean) + + const emailResponse = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${resendApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: 'PolicyEngine Team ', + to: recipients, + subject: `Score bill request: ${payload.state} ${payload.bill_number}`, + html: [ + '

A new bill analysis request was submitted.

', + `

Requester: ${payload.requester_email}

`, + `

Newsletter opt-in: ${payload.subscribe_newsletter ? 'yes' : 'no'}

`, + `

Source: ${payload.request_source || 'unknown'}

`, + `

Bill: ${payload.state} ${payload.bill_number}

`, + `

Title: ${payload.title}

`, + `

Link: ${payload.bill_url}

`, + ].join(''), + }), + }) + + if (!emailResponse.ok) { + const text = await emailResponse.text() + throw new Error(text || 'Could not send notification email.') + } + + notificationSent = true + } + + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ stored: true, notification_sent: notificationSent, local_dev: true })) + } catch (error) { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ detail: error.message || 'Could not submit request.' })) + } + }) + }, + } +} + // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - build: { assetsDir: '_tracker' }, - server: { - proxy: { - '/ingest': { - target: 'https://us.i.posthog.com', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/ingest/, ''), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [react(), tailwindcss(), createLocalBillRequestPlugin(env)], + build: { assetsDir: '_tracker' }, + server: { + proxy: { + '/ingest': { + target: 'https://us.i.posthog.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/ingest/, ''), + }, }, }, - }, + } })