Implement deactivation: move lapsed members to 'inactive' group (keep data), map webhook slug→email

This commit is contained in:
inference-bot committed 2026-09-05 22:40:11 -06:00
1 parent 051f898667
commit e291406b0b
1 file changed
+49 -15
+49 -15
View File
@@ -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)