indie-status-page/app/api/organizations.py

155 lines
No EOL
4.4 KiB
Python

"""Organization API endpoints: view org, list tiers, upgrade/downgrade."""
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, User
from app.services.tier_limits import (
TIER_LIMITS,
get_org_limits,
get_tier_info,
)
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"
# ── 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)