Idempotent provisioning + auto re-invite of never-activated members
- provision_member reuses the stored LiteLLM key instead of minting a duplicate alias (LiteLLM 400s on member:<email>); fixes re-provisioning of existing members - store_member preserves an existing slug when none is passed - add welcome_sent_at + REINVITE_AFTER_DAYS; nightly sweep re-invites active members who never completed account setup, throttled to 3 days
This commit is contained in:
1 parent
2625a6cc83
commit
fd7442e1a4
1 file changed
+120
-2
+120
-2
@@ -20,6 +20,7 @@ import re
|
||||
import sqlite3
|
||||
import secrets
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
@@ -96,6 +97,12 @@ OC_PERSONAL_TOKEN = os.environ.get("OC_PERSONAL_TOKEN", "")
|
||||
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
||||
MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
|
||||
|
||||
# Days to wait before re-sending the welcome/invite email to a member who was
|
||||
# provisioned but never completed account setup (inviteAccepted=False). The
|
||||
# nightly sweep re-invites such members, throttled by this interval so they
|
||||
# aren't emailed every single night.
|
||||
REINVITE_AFTER_DAYS = float(os.environ.get("REINVITE_AFTER_DAYS", "3"))
|
||||
|
||||
# Models every member key/team may access.
|
||||
MEMBER_MODELS = ["deepseek-v4-1-flash", "gpt-oss-120b", "glm-5-3-flash"]
|
||||
|
||||
@@ -266,6 +273,10 @@ def get_db() -> sqlite3.Connection:
|
||||
# This is the flexible counter to the fixed $15/month allowance.
|
||||
if "balance" not in cols:
|
||||
conn.execute("ALTER TABLE members ADD COLUMN balance REAL")
|
||||
# Migration: welcome_sent_at — timestamp of the last welcome/invite email,
|
||||
# used to throttle re-invites of members who never activated.
|
||||
if "welcome_sent_at" not in cols:
|
||||
conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -497,6 +508,13 @@ async def sync_loomio_memberships() -> None:
|
||||
|
||||
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||
conn = get_db()
|
||||
# Preserve an existing slug if the caller passed none — re-provisioning an
|
||||
# already-provisioned member must not wipe their OC slug (the deactivation
|
||||
# webhook maps slugs back to members).
|
||||
if not slug:
|
||||
row = conn.execute("SELECT slug FROM members WHERE email = ?", (email,)).fetchone()
|
||||
if row and row[0]:
|
||||
slug = row[0]
|
||||
conn.execute(
|
||||
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
|
||||
"VALUES (?, ?, ?, ?, 1) "
|
||||
@@ -517,6 +535,33 @@ def get_member_key(email: str) -> str | None:
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def get_stored_key(email: str) -> str | None:
|
||||
"""Return the stored LiteLLM key token regardless of active status.
|
||||
|
||||
Unlike get_member_key (which filters active=1), this returns the key even
|
||||
for a lapsed member being reactivated, so provisioning can reuse it rather
|
||||
than mint a duplicate alias (LiteLLM rejects a duplicate `member:<email>`
|
||||
alias with 400).
|
||||
"""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT key_token FROM members WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return (row[0] if row and row[0] else None)
|
||||
|
||||
|
||||
def touch_welcome_sent(email: str) -> None:
|
||||
"""Record that a welcome/invite email was just sent for this member."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE members SET welcome_sent_at = datetime('now') WHERE email = ?",
|
||||
(email,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
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()
|
||||
@@ -644,6 +689,25 @@ async def cloudron_get_invite_link(user_id: str) -> str:
|
||||
return r.json().get("inviteLink", "")
|
||||
|
||||
|
||||
async def cloudron_is_activated(user_id: str) -> bool:
|
||||
"""Return True if the user has completed account setup (inviteAccepted).
|
||||
|
||||
`inviteAccepted` flips true once the member clicks their setup link and
|
||||
chooses a username/password. Until then they're provisioned but not able to
|
||||
log in — the state we re-invite for.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{CLOUDRON_API}/api/v1/users",
|
||||
headers=cloudron_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
for u in r.json().get("users", []):
|
||||
if u.get("id") == user_id:
|
||||
return bool(u.get("inviteAccepted"))
|
||||
return False
|
||||
|
||||
|
||||
async def provision_member(email: str, name: str, slug: str = "") -> str:
|
||||
"""Provision a member end-to-end and send our own welcome email.
|
||||
|
||||
@@ -652,14 +716,23 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
|
||||
stores the mapping, and sends our branded welcome email containing the
|
||||
Cloudron account-setup link (instead of Cloudron's default invite email).
|
||||
|
||||
Idempotent: if the member already exists, reuses the existing user/key.
|
||||
Returns the Cloudron user id.
|
||||
Idempotent: if the member already has a stored LiteLLM key, reuse it rather
|
||||
than minting a new one (LiteLLM rejects a duplicate `member:<email>` alias
|
||||
with 400 — the bug that previously made re-provisioning of existing members
|
||||
fail). Re-sending this way also re-sends the welcome email, so it doubles
|
||||
as the manual "re-invite" path.
|
||||
"""
|
||||
user_id = await cloudron_create_user(email, name)
|
||||
await cloudron_set_group(user_id)
|
||||
await cloudron_set_active(user_id, True)
|
||||
balance = get_member_balance(email)
|
||||
team_id = await litellm_get_or_create_team(email, balance)
|
||||
|
||||
# Reuse an existing key if we already have one (the sk- value is stored in
|
||||
# our DB at creation time and is the authoritative token for this member's
|
||||
# chat key). Only mint a fresh key when there is none.
|
||||
key_token = get_stored_key(email)
|
||||
if not key_token:
|
||||
key_token = await litellm_create_key(email, balance, team_id)
|
||||
store_member(email, key_token, user_id, slug)
|
||||
|
||||
@@ -667,6 +740,7 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
|
||||
setup_url = await cloudron_get_invite_link(user_id)
|
||||
subject, text, html = welcome_email(name, setup_url)
|
||||
send_email(email, subject, text, html)
|
||||
touch_welcome_sent(email)
|
||||
|
||||
# Subscribe to the member newsletter (single opt-in; members consented by joining).
|
||||
await listmonk_sync(email, subscribe=True)
|
||||
@@ -1392,6 +1466,7 @@ async def reconcile_memberships() -> dict:
|
||||
"""
|
||||
active = await fetch_members()
|
||||
active_emails = {m["email"] for m in active}
|
||||
active_by_email = {m["email"]: m for m in active}
|
||||
|
||||
# FAIL-SAFE: if the OC fetch returned nothing (token expired, OC outage,
|
||||
# or a query error), we must NOT deactivate everyone — that would be a
|
||||
@@ -1434,6 +1509,48 @@ async def reconcile_memberships() -> dict:
|
||||
except Exception as e:
|
||||
logger.warning("Sweep: failed to deactivate %s: %s", email, e)
|
||||
|
||||
# 2b. Re-invite never-activated members (provisioned but no account setup).
|
||||
# A member who was provisioned (e.g. webhook fired while the OC token was
|
||||
# missing the `email` scope, or the invite email went to spam) may sit
|
||||
# active-but-unactivated indefinitely. Re-send their welcome email, but
|
||||
# throttle by REINVITE_AFTER_DAYS so we don't spam them nightly.
|
||||
reinvited_count = 0
|
||||
conn = get_db()
|
||||
reinvite_rows = conn.execute(
|
||||
"SELECT email, cloudron_user_id, welcome_sent_at FROM members WHERE active = 1"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
now = datetime.now(timezone.utc)
|
||||
for email, user_id, sent_at in reinvite_rows:
|
||||
if not user_id:
|
||||
continue
|
||||
# Skip if already activated.
|
||||
try:
|
||||
if await cloudron_is_activated(user_id):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("Sweep: activation check failed for %s: %s", email, e)
|
||||
continue
|
||||
# Throttle: only re-invite if the last send was > REINVITE_AFTER_DAYS ago.
|
||||
if sent_at:
|
||||
try:
|
||||
last = datetime.fromisoformat(sent_at)
|
||||
# stored as UTC naive (datetime('now') is UTC); attach tz
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
if (now - last).total_seconds() < REINVITE_AFTER_DAYS * 86400:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# Re-send the welcome email (reuses the stored key — no duplicate alias).
|
||||
try:
|
||||
name = active_by_email.get(email, {}).get("name") or email.split("@")[0]
|
||||
await provision_member(email, name, "")
|
||||
reinvited_count += 1
|
||||
logger.info("Sweep: re-invited %s (never activated)", email)
|
||||
except Exception as e:
|
||||
logger.warning("Sweep: failed to re-invite %s: %s", email, e)
|
||||
|
||||
# 3. Sync Loomio.
|
||||
await sync_loomio_memberships()
|
||||
|
||||
@@ -1441,6 +1558,7 @@ async def reconcile_memberships() -> dict:
|
||||
"status": "reconciled",
|
||||
"provisioned": provisioned_count,
|
||||
"deactivated": deactivated_count,
|
||||
"reinvited": reinvited_count,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user