feat: status page enhancements
This commit is contained in:
parent
158a6ee716
commit
44d353a30f
5 changed files with 561 additions and 14 deletions
|
|
@ -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.",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue