indie-status-page/app/api/organizations.py
2026-04-25 12:14:06 +00:00

322 lines
No EOL
9.4 KiB
Python

"""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
from sqlalchemy import select
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, 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"])
# ── Response schemas ────────────────────────────────────────────────────────
class OrgMemberResponse(BaseModel):
user_id: str
email: str
display_name: str | None
role: str
class OrgResponse(BaseModel):
id: str
slug: str
name: str
tier: str
custom_domain: str | None
member_count: int
tier_info: dict
class TierDetailResponse(BaseModel):
tier: str
limits: dict
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")
async def list_tiers():
"""List all available tiers and their limits (public endpoint)."""
return {
"tiers": [
{
"name": tier_name,
"display_name": {
"free": "Free",
"pro": "Pro ($9/mo)",
"team": "Team ($29/mo)",
}.get(tier_name, tier_name),
"limits": limits,
}
for tier_name, limits in TIER_LIMITS.items()
]
}
@router.get("/my", response_model=OrgResponse)
async def get_my_org(
org: Organization = Depends(get_current_org),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's organization with tier limits info."""
# Count members
result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id
)
)
members = result.scalars().all()
return OrgResponse(
id=org.id,
slug=org.slug,
name=org.name,
tier=org.tier or "free",
custom_domain=org.custom_domain,
member_count=len(members),
tier_info=get_tier_info(org),
)
@router.patch("/my/tier", response_model=OrgResponse)
async def update_org_tier(
body: UpgradeRequest,
org: Organization = Depends(get_current_org),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update the organization's tier.
In production, this would be gated by Stripe payment verification.
For now, this is an admin-only endpoint that directly sets the tier.
Only the org owner can change the tier.
"""
# Verify user is owner
result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id,
OrganizationMember.user_id == user.id,
)
)
membership = result.scalar_one_or_none()
if not membership or membership.role != "owner":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the organization owner can change the plan tier.",
)
if body.tier not in ("free", "pro", "team"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid tier '{body.tier}'. Must be one of: free, pro, team.",
)
org.tier = body.tier
await db.flush()
await db.refresh(org)
# Count members for response
members_result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id
)
)
members = members_result.scalars().all()
return OrgResponse(
id=org.id,
slug=org.slug,
name=org.name,
tier=org.tier,
custom_domain=org.custom_domain,
member_count=len(members),
tier_info=get_tier_info(org),
)
@router.get("/my/limits")
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)
# ── 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.",
}