"""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.", }