Merge pull request #501 from christianlouis/copilot/add-push-notifications-for-signup

feat(notifications): admin push notifications and webhooks for user signup, plan changes, and payment issues
This commit is contained in:
Christian Krakau-Louis
2026-03-07 22:02:41 +01:00
committed by GitHub
10 changed files with 979 additions and 1 deletions
+70
View File
@@ -69,6 +69,12 @@ class UserProfileUpsert(BaseModel):
)
class PaymentIssueBody(BaseModel):
"""Body for reporting a payment issue for a user."""
issue: str = Field(..., min_length=1, max_length=2048, description="Description of the payment issue")
class UserProfileResponse(BaseModel):
"""Response schema for a user profile record."""
@@ -399,6 +405,7 @@ def upsert_user_profile(
profile = UserProfile(user_id=user_id)
db.add(profile)
old_tier = (profile.subscription_tier or "free") if profile.id else None # None means brand-new profile
profile.display_name = body.display_name
profile.daily_upload_limit = body.daily_upload_limit
profile.notes = body.notes
@@ -407,6 +414,8 @@ def upsert_user_profile(
profile.subscription_period_start = body.subscription_period_start
profile.allow_overage = body.allow_overage
profile.is_complimentary = body.is_complimentary
tier_changed = False
new_tier: str | None = None
if body.subscription_tier is not None:
from app.utils.subscription import TIERS
@@ -415,6 +424,10 @@ def upsert_user_profile(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
)
# Detect a real change only for existing profiles (old_tier is not None)
if old_tier is not None and old_tier != body.subscription_tier:
tier_changed = True
new_tier = body.subscription_tier
profile.subscription_tier = body.subscription_tier
try:
@@ -425,9 +438,66 @@ def upsert_user_profile(
raise
logger.info("Admin upserted profile for user %s", user_id)
# Notify admins and fire webhook when plan is changed by an admin
if tier_changed and new_tier is not None:
try:
from app.utils.notification import notify_plan_changed
from app.utils.webhook import dispatch_webhook_event
notify_plan_changed(user_id, old_tier=old_tier, new_tier=new_tier, changed_by="admin") # type: ignore[arg-type]
dispatch_webhook_event(
"user.plan_changed",
{
"user_id": user_id,
"old_tier": old_tier,
"new_tier": new_tier,
"billing_cycle": body.subscription_billing_cycle,
"changed_by": "admin",
},
)
except Exception:
logger.exception("Failed to send plan-change notification/webhook for user %s", user_id)
return _profile_to_dict(profile)
@router.post(
"/{user_id:path}/payment-issue", status_code=status.HTTP_200_OK, summary="Report a payment issue for a user"
)
def report_payment_issue(user_id: str, body: PaymentIssueBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Notify admins and fire a webhook for a payment issue reported against *user_id*.
The user profile must exist. Use this endpoint when a payment processor
webhook or manual review identifies a billing problem (e.g. failed charge,
expired card, disputed transaction).
Returns the user profile dict alongside an acknowledgement flag.
"""
profile = _get_or_none(db, user_id)
if not profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User profile not found")
logger.warning("Payment issue reported for user %s: %s", user_id, body.issue)
try:
from app.utils.notification import notify_payment_issue
from app.utils.webhook import dispatch_webhook_event
notify_payment_issue(user_id, issue=body.issue)
dispatch_webhook_event(
"user.payment_issue",
{
"user_id": user_id,
"issue": body.issue,
},
)
except Exception:
logger.exception("Failed to send payment-issue notification/webhook for user %s", user_id)
return {"acknowledged": True, "user_id": user_id, "profile": _profile_to_dict(profile)}
@router.delete("/{user_id:path}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a user profile")
def delete_user_profile(user_id: str, db: DbSession, _admin: AdminUser) -> None:
"""Delete the admin-managed profile for *user_id*.
+22
View File
@@ -175,6 +175,7 @@ def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]
)
profile = _get_or_create_profile(db, user_id)
old_tier = profile.subscription_tier or "free"
profile.subscription_tier = body.subscription_tier
profile.subscription_billing_cycle = body.billing_cycle
@@ -186,6 +187,27 @@ def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]
raise
logger.info("Onboarding: saved plan %s/%s", body.subscription_tier, body.billing_cycle)
# Notify admins and fire webhook when the plan actually changes
if old_tier != body.subscription_tier:
try:
from app.utils.notification import notify_plan_changed
from app.utils.webhook import dispatch_webhook_event
notify_plan_changed(user_id, old_tier=old_tier, new_tier=body.subscription_tier, changed_by="user")
dispatch_webhook_event(
"user.plan_changed",
{
"user_id": user_id,
"old_tier": old_tier,
"new_tier": body.subscription_tier,
"billing_cycle": body.billing_cycle,
"changed_by": "user",
},
)
except Exception:
logger.exception("Failed to send plan-change notification/webhook for user %s", user_id)
return _profile_to_dict(profile)
+18
View File
@@ -168,6 +168,7 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
existing = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if existing is None:
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
email = user_data.get("email")
profile = UserProfile(
user_id=user_id,
display_name=display_name,
@@ -183,6 +184,23 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
is_admin,
highest_tier if is_admin else "free",
)
# Notify admins and fire webhook for new (non-admin) user signup
if not is_admin:
try:
from app.utils.notification import notify_user_signup
from app.utils.webhook import dispatch_webhook_event
notify_user_signup(user_id, display_name=display_name, email=email)
dispatch_webhook_event(
"user.signup",
{
"user_id": user_id,
"display_name": display_name,
"email": email,
},
)
except Exception:
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
elif is_admin:
# Ensure existing admin profiles always have complimentary flag set.
# Also upgrade from free tier to highest if still on default.
+12
View File
@@ -404,6 +404,18 @@ class Settings(BaseSettings):
default=True,
description="Send notifications when files are successfully processed",
)
notify_on_user_signup: bool = Field(
default=True,
description="Send admin notifications when a new user signs up",
)
notify_on_plan_change: bool = Field(
default=True,
description="Send admin notifications when a user changes their subscription plan",
)
notify_on_payment_issue: bool = Field(
default=True,
description="Send admin notifications when a payment issue is reported for a user",
)
# Webhook settings
webhook_enabled: bool = Field(
+101
View File
@@ -204,3 +204,104 @@ The file has been successfully processed and is being uploaded to all configured
return send_notification(
title=title, message=message.strip(), notification_type="success", tags=["document", "processed", "success"]
)
def notify_user_signup(user_id: str, display_name: str | None = None, email: str | None = None) -> bool:
"""Send a notification to admins when a new user signs up.
Args:
user_id: The stable user identifier (preferred_username / email / sub).
display_name: Optional human-readable name for the user.
email: Optional email address for the user.
Returns:
bool: True if the notification was sent successfully.
"""
if not settings.notify_on_user_signup:
return False
name_str = display_name or user_id
email_str = email or "N/A"
title = f"New User Signup: {name_str}"
message = f"""A new user has signed up for DocuElevate.
User ID: {user_id}
Display Name: {name_str}
Email: {email_str}
Review the new account in the admin panel."""
return send_notification(
title=title,
message=message.strip(),
notification_type="info",
tags=["user", "signup"],
)
def notify_plan_changed(
user_id: str,
old_tier: str,
new_tier: str,
changed_by: str = "user",
) -> bool:
"""Send a notification to admins when a user changes their subscription plan.
Args:
user_id: The stable user identifier.
old_tier: The previous subscription tier.
new_tier: The new subscription tier.
changed_by: Who initiated the change (``"user"`` or ``"admin"``).
Returns:
bool: True if the notification was sent successfully.
"""
if not settings.notify_on_plan_change:
return False
title = f"Plan Changed: {user_id}"
message = f"""A user's subscription plan has changed.
User ID: {user_id}
Previous Plan: {old_tier}
New Plan: {new_tier}
Changed By: {changed_by}
Review the account in the admin panel."""
return send_notification(
title=title,
message=message.strip(),
notification_type="info",
tags=["user", "plan", "subscription"],
)
def notify_payment_issue(user_id: str, issue: str) -> bool:
"""Send a notification to admins when a payment issue is reported for a user.
Args:
user_id: The stable user identifier.
issue: A human-readable description of the payment issue.
Returns:
bool: True if the notification was sent successfully.
"""
if not settings.notify_on_payment_issue:
return False
title = f"Payment Issue: {user_id}"
message = f"""A payment issue has been reported for a user.
User ID: {user_id}
Issue: {issue}
Please review the account in the admin panel and follow up with the user."""
return send_notification(
title=title,
message=message.strip(),
notification_type="warning",
tags=["user", "payment", "billing"],
)
+24
View File
@@ -1251,6 +1251,30 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"notify_on_user_signup": {
"category": "Notifications",
"description": "Send admin notifications when a new user signs up",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"notify_on_plan_change": {
"category": "Notifications",
"description": "Send admin notifications when a user changes their subscription plan",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"notify_on_payment_issue": {
"category": "Notifications",
"description": "Send admin notifications when a payment issue is reported for a user",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Feature Flags
"allow_file_delete": {
"category": "Feature Flags",
+6
View File
@@ -7,6 +7,9 @@ Supported events:
- ``document.uploaded`` a new document has been ingested
- ``document.processed`` a document finished processing successfully
- ``document.failed`` document processing failed
- ``user.signup`` a new user account was created
- ``user.plan_changed`` a user's subscription plan changed
- ``user.payment_issue`` a payment issue was reported for a user
"""
import hashlib
@@ -29,6 +32,9 @@ VALID_EVENTS: frozenset[str] = frozenset(
"document.uploaded",
"document.processed",
"document.failed",
"user.signup",
"user.plan_changed",
"user.payment_issue",
}
)