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
+121
-3
+121
-3
@@ -20,6 +20,7 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import secrets
|
import secrets
|
||||||
import smtplib
|
import smtplib
|
||||||
|
from datetime import datetime, timezone
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
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).
|
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
||||||
MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
|
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.
|
# Models every member key/team may access.
|
||||||
MEMBER_MODELS = ["deepseek-v4-1-flash", "gpt-oss-120b", "glm-5-3-flash"]
|
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.
|
# This is the flexible counter to the fixed $15/month allowance.
|
||||||
if "balance" not in cols:
|
if "balance" not in cols:
|
||||||
conn.execute("ALTER TABLE members ADD COLUMN balance REAL")
|
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
|
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:
|
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||||
conn = get_db()
|
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(
|
conn.execute(
|
||||||
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
|
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
|
||||||
"VALUES (?, ?, ?, ?, 1) "
|
"VALUES (?, ?, ?, ?, 1) "
|
||||||
@@ -517,6 +535,33 @@ def get_member_key(email: str) -> str | None:
|
|||||||
return row[0] if row else 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:
|
def get_member_by_slug(slug: str) -> dict | None:
|
||||||
"""Look up a member (email, key_token, cloudron_user_id) by their OC slug."""
|
"""Look up a member (email, key_token, cloudron_user_id) by their OC slug."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -644,6 +689,25 @@ async def cloudron_get_invite_link(user_id: str) -> str:
|
|||||||
return r.json().get("inviteLink", "")
|
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:
|
async def provision_member(email: str, name: str, slug: str = "") -> str:
|
||||||
"""Provision a member end-to-end and send our own welcome email.
|
"""Provision a member end-to-end and send our own welcome email.
|
||||||
|
|
||||||
@@ -652,21 +716,31 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
|
|||||||
stores the mapping, and sends our branded welcome email containing the
|
stores the mapping, and sends our branded welcome email containing the
|
||||||
Cloudron account-setup link (instead of Cloudron's default invite email).
|
Cloudron account-setup link (instead of Cloudron's default invite email).
|
||||||
|
|
||||||
Idempotent: if the member already exists, reuses the existing user/key.
|
Idempotent: if the member already has a stored LiteLLM key, reuse it rather
|
||||||
Returns the Cloudron user id.
|
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)
|
user_id = await cloudron_create_user(email, name)
|
||||||
await cloudron_set_group(user_id)
|
await cloudron_set_group(user_id)
|
||||||
await cloudron_set_active(user_id, True)
|
await cloudron_set_active(user_id, True)
|
||||||
balance = get_member_balance(email)
|
balance = get_member_balance(email)
|
||||||
team_id = await litellm_get_or_create_team(email, balance)
|
team_id = await litellm_get_or_create_team(email, balance)
|
||||||
key_token = await litellm_create_key(email, balance, team_id)
|
|
||||||
|
# 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)
|
store_member(email, key_token, user_id, slug)
|
||||||
|
|
||||||
# Send our own welcome email with the account-setup link (no OAuth step).
|
# Send our own welcome email with the account-setup link (no OAuth step).
|
||||||
setup_url = await cloudron_get_invite_link(user_id)
|
setup_url = await cloudron_get_invite_link(user_id)
|
||||||
subject, text, html = welcome_email(name, setup_url)
|
subject, text, html = welcome_email(name, setup_url)
|
||||||
send_email(email, subject, text, html)
|
send_email(email, subject, text, html)
|
||||||
|
touch_welcome_sent(email)
|
||||||
|
|
||||||
# Subscribe to the member newsletter (single opt-in; members consented by joining).
|
# Subscribe to the member newsletter (single opt-in; members consented by joining).
|
||||||
await listmonk_sync(email, subscribe=True)
|
await listmonk_sync(email, subscribe=True)
|
||||||
@@ -1392,6 +1466,7 @@ async def reconcile_memberships() -> dict:
|
|||||||
"""
|
"""
|
||||||
active = await fetch_members()
|
active = await fetch_members()
|
||||||
active_emails = {m["email"] for m in active}
|
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,
|
# 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
|
# 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:
|
except Exception as e:
|
||||||
logger.warning("Sweep: failed to deactivate %s: %s", email, 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.
|
# 3. Sync Loomio.
|
||||||
await sync_loomio_memberships()
|
await sync_loomio_memberships()
|
||||||
|
|
||||||
@@ -1441,6 +1558,7 @@ async def reconcile_memberships() -> dict:
|
|||||||
"status": "reconciled",
|
"status": "reconciled",
|
||||||
"provisioned": provisioned_count,
|
"provisioned": provisioned_count,
|
||||||
"deactivated": deactivated_count,
|
"deactivated": deactivated_count,
|
||||||
|
"reinvited": reinvited_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user