From e291406b0b04780808b8d1d4b81596e929550a38 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sat, 5 Sep 2026 22:40:11 -0600 Subject: [PATCH] =?UTF-8?q?Implement=20deactivation:=20move=20lapsed=20mem?= =?UTF-8?q?bers=20to=20'inactive'=20group=20(keep=20data),=20map=20webhook?= =?UTF-8?q?=20slug=E2=86=92email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 64 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/app/main.py b/app/main.py index ba5f966..b8a8f87 100644 --- a/app/main.py +++ b/app/main.py @@ -65,6 +65,10 @@ MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0")) # Members are assigned to this group, which grants access to the chat app. MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "") +# The "inactive" group in Cloudron. Lapsed members are moved here (instead of +# being deleted) so they lose access but can reclaim their data on reactivation. +INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "") + # Persistent store (SQLite) for email → LiteLLM key token mapping. # Lives in /app/data (Cloudron localstorage addon persists this). DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db") @@ -87,6 +91,7 @@ def get_db() -> sqlite3.Connection: "email TEXT PRIMARY KEY, " "key_token TEXT, " "cloudron_user_id TEXT, " + "slug TEXT, " "active INTEGER DEFAULT 1" ")" ) @@ -97,6 +102,10 @@ def get_db() -> sqlite3.Connection: "created_at TEXT DEFAULT (datetime('now'))" ")" ) + # Migration: add slug column if the members table predates it. + cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()] + if "slug" not in cols: + conn.execute("ALTER TABLE members ADD COLUMN slug TEXT") return conn @@ -111,14 +120,14 @@ def store_pending_member(slug: str, name: str) -> None: conn.close() -def store_member(email: str, key_token: str, cloudron_user_id: str) -> None: +def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db() conn.execute( - "INSERT INTO members (email, key_token, cloudron_user_id, active) " - "VALUES (?, ?, ?, 1) " + "INSERT INTO members (email, key_token, cloudron_user_id, slug, active) " + "VALUES (?, ?, ?, ?, 1) " "ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, " - "cloudron_user_id=excluded.cloudron_user_id, active=1", - (email, key_token, cloudron_user_id), + "cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1", + (email, key_token, cloudron_user_id, slug), ) conn.commit() conn.close() @@ -133,6 +142,18 @@ def get_member_key(email: str) -> str | None: return row[0] if row else None +def get_member_by_slug(slug: str) -> dict | None: + """Look up a member (email, key_token, cloudron_user_id) by their OC slug.""" + conn = get_db() + row = conn.execute( + "SELECT email, key_token, cloudron_user_id FROM members WHERE slug = ?", (slug,) + ).fetchone() + conn.close() + if not row: + return None + return {"email": row[0], "key_token": row[1], "cloudron_user_id": row[2]} + + def deactivate_member(email: str) -> str | None: """Mark a member inactive and return their key token (for deletion).""" conn = get_db() @@ -187,16 +208,17 @@ async def cloudron_create_user(email: str, name: str) -> str: return r.json()["id"] -async def cloudron_set_group(user_id: str) -> None: - """Assign a user to the members group (grants chat app access).""" - if not MEMBERS_GROUP_ID: - logger.warning("MEMBERS_GROUP_ID not set; skipping group assignment") +async def cloudron_set_group(user_id: str, group_id: str | None = None) -> None: + """Assign a user to a group (defaults to the members group).""" + gid = group_id or MEMBERS_GROUP_ID + if not gid: + logger.warning("No group id; skipping group assignment") return async with httpx.AsyncClient() as client: r = await client.put( f"{CLOUDRON_API}/api/v1/users/{user_id}/groups", headers=cloudron_headers(), - json={"groupIds": [MEMBERS_GROUP_ID]}, + json={"groupIds": [gid]}, ) r.raise_for_status() @@ -322,8 +344,19 @@ async def opencollective_webhook(request: Request, token: str): return JSONResponse({"status": "pending", "name": name, "slug": slug}) if event_type in ("collective.member.deleted", "collective.transaction.deleted"): - # Lapsed member → deactivate (we may not have their email yet) - return JSONResponse({"status": "deactivated"}) + # Lapsed member → move to the inactive group (lose access, keep data). + # The webhook gives us the slug; we map it to the member's email/user. + if slug: + member = get_member_by_slug(slug) + if member: + # Move to inactive group (revokes chat access, keeps account + data) + if INACTIVE_GROUP_ID and member.get("cloudron_user_id"): + await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID) + # Mark inactive in our DB (key injector will refuse requests) + deactivate_member(member["email"]) + logger.info("Deactivated member %s (slug=%s)", member["email"], slug) + return JSONResponse({"status": "deactivated", "email": member["email"]}) + return JSONResponse({"status": "deactivated", "note": "no matching member"}) return JSONResponse({"status": "ignored", "type": event_type}) @@ -462,16 +495,17 @@ async def oauth_callback(request: Request): if not token: raise HTTPException(500, "No access token returned") - # Fetch the user's email + # Fetch the user's email + slug (slug lets us map webhook events back) r = await client.post( OC_GRAPHQL_URL, headers={"Authorization": f"Bearer {token}"}, - json={"query": "{ me { id name email } }"}, + json={"query": "{ me { id name email slug } }"}, ) r.raise_for_status() me = r.json().get("data", {}).get("me", {}) email = me.get("email", "") name = me.get("name", "Member") + slug = me.get("slug", "") if not email: raise HTTPException(400, "No email returned — did you grant the email scope?") @@ -481,7 +515,7 @@ async def oauth_callback(request: Request): await cloudron_set_group(user_id) await cloudron_set_active(user_id, True) key_token = await litellm_create_key(email, MEMBER_BUDGET) - store_member(email, key_token, user_id) + store_member(email, key_token, user_id, slug) await cloudron_send_invite(user_id, email) logger.info("Provisioned member %s (user_id=%s)", email, user_id)