feat: indie status page SaaS - initial release

This commit is contained in:
Ubuntu 2026-04-25 09:39:57 +00:00
parent ee2bc87ade
commit b7a8142ca0
14 changed files with 2703 additions and 0 deletions

229
app/services/tier_limits.py Normal file
View file

@ -0,0 +1,229 @@
"""Tier enforcement: limits and feature flags for Free/Pro/Team plans.
This module defines the per-tier limits and provides enforcement functions
that raise HTTP 403 when a limit is exceeded or a feature is unavailable.
"""
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.saas_models import Organization, OrganizationMember, StatusPage
from app.models.models import Service, Monitor, Subscriber
# ── Tier limits configuration ──────────────────────────────────────────────
TIER_LIMITS: dict[str, dict[str, int | bool]] = {
"free": {
"status_pages": 1,
"services_per_page": 5,
"monitors_per_service": 1,
"subscribers": 25,
"members": 1,
"check_interval_min": 5, # minutes
"custom_domain": False,
"custom_branding": False,
"webhooks": False,
"api_access": False,
"incident_history_days": 30,
"sla_badge": False,
"password_protection": False,
},
"pro": {
"status_pages": 5,
"services_per_page": 50,
"monitors_per_service": 5,
"subscribers": 500,
"members": 3,
"check_interval_min": 1, # minutes
"custom_domain": True,
"custom_branding": True,
"webhooks": True,
"api_access": True,
"incident_history_days": 365,
"sla_badge": True,
"password_protection": False,
},
"team": {
"status_pages": -1, # unlimited
"services_per_page": -1,
"monitors_per_service": -1,
"subscribers": -1,
"members": -1,
"check_interval_min": 0, # 30 seconds (0 min)
"custom_domain": True,
"custom_branding": True,
"webhooks": True,
"api_access": True,
"incident_history_days": -1, # unlimited
"sla_badge": True,
"password_protection": True,
},
}
# ── Tier info helpers ───────────────────────────────────────────────────────
def get_tier_limits(tier: str) -> dict:
"""Return the limits dict for a given tier name. Falls back to free."""
return TIER_LIMITS.get(tier, TIER_LIMITS["free"])
def get_org_limits(org: Organization) -> dict:
"""Return the limits dict for an organization based on its tier."""
return get_tier_limits(org.tier or "free")
def get_limit(org: Organization, feature: str):
"""Return the limit value for a specific feature given the org's tier.
Returns:
int: numeric limit (-1 means unlimited)
bool: feature flag (True/False)
None: unknown feature
"""
limits = get_org_limits(org)
return limits.get(feature)
# ── Enforcement ─────────────────────────────────────────────────────────────
class TierLimitExceeded(HTTPException):
"""Raised when a tier limit is exceeded."""
def __init__(self, feature: str, limit: int | bool):
if limit is False:
detail = f"Feature '{feature}' is not available on your current plan. Upgrade to access it."
else:
detail = (
f"Tier limit reached for '{feature}' ({limit}). "
"Upgrade your plan to increase this limit."
)
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
async def enforce_limit(
db: AsyncSession,
org: Organization,
feature: str,
current_count: int,
) -> None:
"""Raise TierLimitExceeded if the current count meets or exceeds the tier limit.
Args:
db: Database session for queries.
org: The organization whose tier limits to check.
feature: The feature name (key in TIER_LIMITS).
current_count: How many of this feature the org currently has.
Raises:
TierLimitExceeded: If the limit is reached or the feature is disabled.
"""
limit = get_limit(org, feature)
if limit is None:
return # Unknown feature — don't block
if limit is False:
# Feature flag: not available on this tier
raise TierLimitExceeded(feature, limit)
if limit == -1:
return # Unlimited
if isinstance(limit, int) and current_count >= limit:
raise TierLimitExceeded(feature, limit)
# ── Concrete enforcement helpers ────────────────────────────────────────────
async def check_status_page_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its status page limit."""
result = await db.execute(
select(func.count(StatusPage.id)).where(
StatusPage.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "status_pages", count)
async def check_service_limit(
db: AsyncSession, org: Organization, status_page_id: str | None = None
) -> None:
"""Check that the org hasn't exceeded its services-per-page limit.
If status_page_id is None, counts all services for the org.
"""
query = select(func.count(Service.id)).where(
Service.organization_id == org.id
)
if status_page_id:
# In future, Service will have a status_page_id column
# For now, count all services in the org
pass
result = await db.execute(query)
count = result.scalar() or 0
await enforce_limit(db, org, "services_per_page", count)
async def check_monitor_limit(
db: AsyncSession, org: Organization, service_id: str
) -> None:
"""Check that the service hasn't exceeded its monitors-per-service limit."""
result = await db.execute(
select(func.count(Monitor.id)).where(Monitor.service_id == service_id)
)
count = result.scalar() or 0
await enforce_limit(db, org, "monitors_per_service", count)
async def check_subscriber_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its subscriber limit."""
result = await db.execute(
select(func.count(Subscriber.id)).where(
Subscriber.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "subscribers", count)
async def check_member_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its team member limit."""
result = await db.execute(
select(func.count(OrganizationMember.id)).where(
OrganizationMember.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "members", count)
def enforce_feature(org: Organization, feature: str) -> None:
"""Enforce a boolean feature flag. Raises TierLimitExceeded if False.
Use this for features that are either allowed or not (e.g., custom_domain,
webhooks, api_access) without a numeric limit.
"""
limit = get_limit(org, feature)
if limit is False:
raise TierLimitExceeded(feature, False)
if limit is None:
# Unknown feature — don't block
return
def get_tier_info(org: Organization) -> dict:
"""Return a dict of the org's current tier with limits and feature flags.
Useful for API responses that show the org what they can and can't do.
"""
limits = get_org_limits(org)
return {
"tier": org.tier or "free",
"limits": limits,
"organization_id": org.id,
"organization_slug": org.slug,
}