""" Business News Routes (ADR-0015) ────────────────────────────────────────────────────────────────────────────── Routes: POST /api/news/poll → admin-triggered scrape+classify run (max 30 days back) GET /api/news/admin/polls → admin: recent poll runs and their outcome GET /api/news/items → classified, unexpired items (optionally filtered by business type) GET /api/news/admin/review → admin: items flagged incomplete, awaiting a human fix PATCH /api/news/admin/review/{id} → admin: manually complete or reject a flagged item Polling runs as a background task (see news/pipeline.py) because scraping every source and classifying each new item through Groq takes minutes — far past any gateway's request timeout. POST /poll returns as soon as the run is recorded; the frontend watches GET /admin/polls for it to finish. """ from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks from pydantic import BaseModel, Field from firebase_admin import firestore from auth import verify_firebase_token from news.pipeline import start_poll, execute_poll from news.classify import IMPACTS, CATEGORIES from news.business_types import valid_subset from utils.supabase_client import get_supabase from utils.audit import log_action from intel.events import record_news_item router = APIRouter() def _verify_admin(user: dict) -> str: uid = user.get("uid") if not uid: raise HTTPException(status_code=401, detail="Unauthorized.") db = firestore.client() doc = db.collection("user_data").document(uid).get() role = doc.to_dict().get("role", "user") if doc.exists else "user" if role != "admin": raise HTTPException(status_code=403, detail="Admin access required.") return uid class PollRequest(BaseModel): since_days: int = Field(default=7, ge=1, le=30) @router.post("/poll") def poll_news(body: PollRequest, background_tasks: BackgroundTasks, user: dict = Depends(verify_firebase_token)): admin_uid = _verify_admin(user) poll_id = start_poll(since_days=body.since_days, triggered_by=admin_uid) background_tasks.add_task(execute_poll, poll_id, body.since_days) log_action(admin_uid, "run", "started", f"Looking back {body.since_days} day(s)", target=poll_id) return {"poll_id": poll_id, "started": True} @router.get("/admin/overview") def admin_overview(user: dict = Depends(verify_firebase_token)): _verify_admin(user) db = get_supabase() def count(status: str) -> int: res = db.table("news_items").select("id", count="exact").eq("status", status).execute() return res.count or 0 sources = db.table("news_sources").select("active").execute().data last_poll = ( db.table("news_polls") .select("id, started_at, finished_at, error") .order("started_at", desc=True) .limit(1) .execute() .data ) return { "published": count("classified"), "needs_review": count("needs_review"), "rejected": count("rejected"), "sources_active": sum(1 for s in sources if s["active"]), "sources_total": len(sources), "last_poll": last_poll[0] if last_poll else None, } @router.get("/admin/sources") def list_sources(user: dict = Depends(verify_firebase_token)): _verify_admin(user) db = get_supabase() sources = db.table("news_sources").select("*").order("name").execute().data return {"sources": sources} class SourceUpdate(BaseModel): active: bool @router.patch("/admin/sources/{source_id}") def update_source(source_id: str, body: SourceUpdate, user: dict = Depends(verify_firebase_token)): admin_uid = _verify_admin(user) db = get_supabase() updated = db.table("news_sources").update({"active": body.active}).eq("id", source_id).execute().data name = updated[0]["name"] if updated else source_id log_action(admin_uid, "source", "enabled" if body.active else "disabled", name, target=source_id) return {"success": True} @router.get("/admin/polls") def list_polls(user: dict = Depends(verify_firebase_token)): _verify_admin(user) db = get_supabase() polls = ( db.table("news_polls") .select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, items_rejected, current_source, error") .order("started_at", desc=True) .limit(10) .execute() .data ) return {"polls": polls} @router.get("/items") def list_items(business_type: str | None = None, user: dict = Depends(verify_firebase_token)): db = get_supabase() now_iso = datetime.now(timezone.utc).isoformat() query = ( db.table("news_items") .select("id, title, url, impact, category, business_types, location, deadline, expires_at, published_at") .eq("status", "classified") .gt("expires_at", now_iso) .order("published_at", desc=True) .limit(100) ) if business_type: query = query.contains("business_types", [business_type]) return {"items": query.execute().data} @router.get("/admin/items") def admin_list_items( status: str | None = None, limit: int = 50, offset: int = 0, user: dict = Depends(verify_firebase_token), ): _verify_admin(user) db = get_supabase() query = db.table("news_items").select( "id, title, url, impact, category, business_types, location, confidence, status, scraped_at, published_at, expires_at", count="exact", ).order("scraped_at", desc=True).range(offset, offset + limit - 1) if status: query = query.eq("status", status) res = query.execute() return {"items": res.data, "total": res.count or 0} @router.get("/admin/review") def list_review_queue(user: dict = Depends(verify_firebase_token)): _verify_admin(user) db = get_supabase() items = ( db.table("news_items") .select("id, title, url, raw_text, impact, category, business_types, location, review_reason, scraped_at") .eq("status", "needs_review") .order("scraped_at", desc=True) .limit(100) .execute() .data ) return {"items": items} class ReviewFix(BaseModel): status: str = Field(pattern="^(classified|rejected)$") impact: str | None = None category: str | None = None business_types: list[str] | None = None location: str | None = None @router.patch("/admin/review/{item_id}") def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)): admin_uid = _verify_admin(user) db = get_supabase() if body.status == "rejected": updated = db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute().data title = updated[0]["title"] if updated else item_id log_action(admin_uid, "review", "discarded", title, target=item_id) return {"success": True, "status": "rejected"} # Completing by hand still has to satisfy the same bar automated classification does. if body.impact and body.impact not in IMPACTS: raise HTTPException(status_code=400, detail=f"impact must be one of {IMPACTS}") if body.category and body.category not in CATEGORIES: raise HTTPException(status_code=400, detail=f"category must be one of {CATEGORIES}") business_types = valid_subset(body.business_types or []) if not (body.category and body.location and business_types): raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.") updated = db.table("news_items").update({ "status": "classified", "impact": body.impact, "category": body.category, "business_types": business_types, "location": body.location, "review_reason": None, "classified_at": datetime.now(timezone.utc).isoformat(), }).eq("id", item_id).execute().data title = updated[0]["title"] if updated else item_id log_action(admin_uid, "review", "published", title, target=item_id) if updated: record_news_item(updated[0]) return {"success": True, "status": "classified"}