feat: indie status page SaaS - initial release
This commit is contained in:
parent
ee2bc87ade
commit
b7a8142ca0
14 changed files with 2703 additions and 0 deletions
155
app/api/organizations.py
Normal file
155
app/api/organizations.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""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)
|
||||
|
|
@ -5,9 +5,13 @@ from app.api.incidents import router as incidents_router
|
|||
from app.api.monitors import router as monitors_router
|
||||
from app.api.subscribers import router as subscribers_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.organizations import router as organizations_router
|
||||
from app.routes.auth import router as auth_router
|
||||
|
||||
api_v1_router = APIRouter()
|
||||
|
||||
api_v1_router.include_router(auth_router, tags=["auth"])
|
||||
api_v1_router.include_router(organizations_router, prefix="/organizations", tags=["organizations"])
|
||||
api_v1_router.include_router(services_router, prefix="/services", tags=["services"])
|
||||
api_v1_router.include_router(incidents_router, prefix="/incidents", tags=["incidents"])
|
||||
api_v1_router.include_router(monitors_router, prefix="/monitors", tags=["monitors"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue