From 5d3ace8fe82c55ee90489e1f75c1a3631af96f1e Mon Sep 17 00:00:00 2001 From: inference-bot Date: Wed, 9 Sep 2026 14:34:30 -0600 Subject: [PATCH] Fix Loomio sync: use portal DB (OC returns email:null); add manual sync endpoint --- app/main.py | 49 ++++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/app/main.py b/app/main.py index 0dcf71d..f6cde5c 100644 --- a/app/main.py +++ b/app/main.py @@ -182,25 +182,20 @@ async def is_active_member(slug: str) -> bool: async def get_active_member_emails() -> list[str]: - """Return the list of active financial contributor emails from Open Collective.""" - query = ( - '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email slug } } } } }' - % OC_COLLECTIVE_SLUG - ) - async with httpx.AsyncClient() as client: - r = await client.post(OC_GRAPHQL_URL, json={"query": query}) - if r.status_code != 200: - logger.warning("OC member list fetch failed: %s", r.status_code) - return [] - nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []) - emails = [] - for n in nodes: - role = n.get("role", "") - acct = n.get("account", {}) or {} - email = acct.get("email") - if role in ("BACKER", "ADMIN") and email: - emails.append(email) - return emails + """Return the list of active member emails from the portal's own database. + + We use the local `members` table (populated during OAuth, which captures the + member's email) rather than Open Collective's GraphQL, because OC returns + `email: null` for privacy — the email field is only exposed via OAuth with + the user's consent. The local table is the authoritative source of member + emails. + """ + conn = get_db() + rows = conn.execute( + "SELECT email FROM members WHERE active = 1" + ).fetchall() + conn.close() + return [r[0] for r in rows] async def sync_loomio_memberships() -> None: @@ -739,6 +734,22 @@ async def health(): return {"status": "ok"} +@app.post("/admin/sync-loomio/{token}") +async def admin_sync_loomio(token: str): + """Manually trigger a Loomio membership sync. + + Protected by the same secret token as the Open Collective webhook. Useful + for reconciling pre-existing members (accounts created before the sync + existed) or recovering from a missed webhook. Idempotent — safe to call + repeatedly. + """ + if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): + raise HTTPException(401, "Invalid token") + + await sync_loomio_memberships() + return {"status": "synced"} + + @app.get("/") async def index(): return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}