feat: status page enhancements

This commit is contained in:
Ubuntu 2026-04-25 12:14:06 +00:00
parent 158a6ee716
commit 44d353a30f
5 changed files with 561 additions and 14 deletions

View file

@ -1,4 +1,7 @@
"""Organization API endpoints: view org, list tiers, upgrade/downgrade."""
"""Organization API endpoints: view org, list tiers, upgrade/downgrade, feature flags.
All org-scoped endpoints enforce tier limits on resource creation.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@ -7,11 +10,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user, get_current_org
from app.dependencies import get_db
from app.models.saas_models import Organization, OrganizationMember, User
from app.models.saas_models import Organization, OrganizationMember, OrganizationMember, User
from app.services.tier_enforcement import (
enforce_member_limit,
enforce_status_page_limit,
enforce_feature_flag,
)
from app.services.tier_limits import (
TIER_LIMITS,
get_org_limits,
get_tier_info,
TierLimitExceeded,
)
router = APIRouter(tags=["organizations"])
@ -45,6 +54,15 @@ class UpgradeRequest(BaseModel):
tier: str # "free" | "pro" | "team"
class InviteMemberRequest(BaseModel):
email: str
role: str = "member" # "member" | "admin"
class SetCustomDomainRequest(BaseModel):
domain: str
# ── Endpoints ───────────────────────────────────────────────────────────────
@router.get("/tiers")
@ -152,4 +170,153 @@ async def get_my_limits(
org: Organization = Depends(get_current_org),
):
"""Get the current organization's tier limits and feature flags."""
return get_tier_info(org)
return get_tier_info(org)
# ── Member management ─────────────────────────────────────────────────────
@router.post("/my/members", status_code=status.HTTP_201_CREATED)
async def invite_member(
body: InviteMemberRequest,
org: Organization = Depends(get_current_org),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Invite a new member to the organization.
Enforces the org's member limit based on tier.
"""
# Enforce member limit
try:
await enforce_member_limit(db, org)
except TierLimitExceeded as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=e.detail,
)
# Find the user by email
result = await db.execute(
select(User).where(User.email == body.email)
)
invited_user = result.scalar_one_or_none()
if not invited_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with email '{body.email}' not found. They must register first.",
)
# Check if already a member
existing = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id,
OrganizationMember.user_id == invited_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User '{body.email}' is already a member of this organization.",
)
if body.role not in ("member", "admin"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Role must be 'member' or 'admin'",
)
membership = OrganizationMember(
organization_id=org.id,
user_id=invited_user.id,
role=body.role,
)
db.add(membership)
await db.flush()
return {
"user_id": invited_user.id,
"email": invited_user.email,
"role": body.role,
"organization_id": org.id,
}
@router.get("/my/members")
async def list_members(
org: Organization = Depends(get_current_org),
db: AsyncSession = Depends(get_db),
):
"""List all members of the organization."""
result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id
)
)
memberships = result.scalars().all()
members = []
for m in memberships:
user_result = await db.execute(
select(User).where(User.id == m.user_id)
)
member_user = user_result.scalar_one_or_none()
if member_user:
members.append({
"user_id": m.user_id,
"email": member_user.email,
"display_name": member_user.display_name,
"role": m.role,
"joined_at": m.joined_at.isoformat() if m.joined_at else None,
})
return {"members": members, "count": len(members)}
# ── Custom domain ────────────────────────────────────────────────────────
@router.post("/my/custom-domain")
async def set_custom_domain(
body: SetCustomDomainRequest,
org: Organization = Depends(get_current_org),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set a custom domain for the organization's status page.
Enforces the custom_domain feature flag based on tier.
Free tier does not have custom domain support.
"""
# Enforce custom_domain feature flag
try:
enforce_feature_flag(org, "custom_domain")
except TierLimitExceeded as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=e.detail,
)
org.custom_domain = body.domain
await db.flush()
return {
"organization_id": org.id,
"custom_domain": org.custom_domain,
"message": "Custom domain set. Please add a CNAME record pointing to your status page.",
}
@router.delete("/my/custom-domain")
async def remove_custom_domain(
org: Organization = Depends(get_current_org),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Remove the custom domain from the organization's status page."""
org.custom_domain = None
await db.flush()
return {
"organization_id": org.id,
"custom_domain": None,
"message": "Custom domain removed.",
}