feat: status page enhancements

This commit is contained in:
Ubuntu 2026-04-25 12:14:06 +00:00
parent 158a6ee716
commit 44d353a30f
5 changed files with 561 additions and 14 deletions

View file

@ -1,4 +1,9 @@
"""Monitors API endpoints."""
"""Monitors API endpoints with tier enforcement.
When creating a monitor for an organization, tier enforcement checks:
- monitors_per_service: max number of monitors per service
- check_interval_min: minimum allowed check interval
"""
from uuid import UUID
@ -8,7 +13,13 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Monitor, MonitorResult
from app.models.models import Monitor, MonitorResult, Service
from app.models.saas_models import Organization
from app.services.tier_enforcement import (
enforce_monitor_limit,
validate_check_interval,
)
from app.services.tier_limits import TierLimitExceeded, get_tier_limits
router = APIRouter()
@ -20,6 +31,7 @@ class MonitorCreate(BaseModel):
expected_status: int = 200
timeout_seconds: int = Field(10, ge=1, le=60)
interval_seconds: int = Field(60, ge=30, le=3600)
organization_id: str | None = None # Optional: for tier enforcement
class MonitorUpdate(BaseModel):
@ -41,6 +53,7 @@ def serialize_monitor(m: Monitor) -> dict:
"timeout_seconds": m.timeout_seconds,
"interval_seconds": m.interval_seconds,
"is_active": m.is_active,
"organization_id": m.organization_id,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
@ -60,7 +73,43 @@ async def create_monitor(
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new monitor."""
"""Create a new monitor.
If organization_id is provided, tier enforcement is applied:
- check monitors_per_service limit
- validate check_interval against minimum for the tier
"""
# Look up org if provided
org = None
if data.organization_id:
result = await db.execute(
select(Organization).where(Organization.id == data.organization_id)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization '{data.organization_id}' not found",
)
# Tier enforcement when org context is provided
if org is not None:
# Check monitors_per_service limit
await enforce_monitor_limit(db, org, str(data.service_id))
# Validate check interval
validate_check_interval(org, data.interval_seconds)
# Verify the service exists
service_result = await db.execute(
select(Service).where(Service.id == str(data.service_id))
)
service = service_result.scalar_one_or_none()
if not service:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{data.service_id}' not found",
)
monitor = Monitor(
service_id=str(data.service_id),
url=data.url,
@ -68,6 +117,7 @@ async def create_monitor(
expected_status=data.expected_status,
timeout_seconds=data.timeout_seconds,
interval_seconds=data.interval_seconds,
organization_id=data.organization_id or (service.organization_id if service else None),
)
db.add(monitor)
await db.flush()
@ -115,13 +165,26 @@ async def update_monitor(
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Update a monitor."""
"""Update a monitor.
If interval_seconds is being changed, validate against the org's tier minimum.
"""
result = await db.execute(select(Monitor).where(Monitor.id == str(monitor_id)))
monitor = result.scalar_one_or_none()
if not monitor:
raise HTTPException(status_code=404, detail="Monitor not found")
update_data = data.model_dump(exclude_unset=True)
# If updating interval_seconds and org context exists, validate
if data.interval_seconds is not None and monitor.organization_id:
org_result = await db.execute(
select(Organization).where(Organization.id == monitor.organization_id)
)
org = org_result.scalar_one_or_none()
if org:
validate_check_interval(org, data.interval_seconds)
for field, value in update_data.items():
setattr(monitor, field, value)

View file

@ -1,4 +1,7 @@
"""Organization API endpoints: view org, list tiers, upgrade/downgrade."""
"""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
@ -7,11 +10,17 @@ 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.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"])
@ -45,6 +54,15 @@ 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")
@ -152,4 +170,153 @@ 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)
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.",
}

View file

@ -1,4 +1,11 @@
"""Services API endpoints."""
"""Services API endpoints with tier enforcement.
Provides both admin API-key endpoints (no org context) and
organization-scoped endpoints with tier enforcement.
When X-Organization-ID header is provided with a valid API key,
tier enforcement is applied to creation endpoints.
"""
from uuid import UUID
@ -9,6 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Service
from app.models.saas_models import Organization
from app.services.tier_enforcement import (
enforce_service_limit,
get_org_if_provided,
)
router = APIRouter()
@ -20,6 +32,7 @@ class ServiceCreate(BaseModel):
group_name: str | None = Field(None, max_length=50)
position: int = 0
is_visible: bool = True
organization_id: str | None = None
class ServiceUpdate(BaseModel):
@ -40,6 +53,7 @@ def serialize_service(s: Service) -> dict:
"group_name": s.group_name,
"position": s.position,
"is_visible": s.is_visible,
"organization_id": s.organization_id,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
@ -59,7 +73,28 @@ async def create_service(
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Create a new service."""
"""Create a new service.
If organization_id is provided in the request body and a matching org
exists, tier enforcement is applied to ensure the org hasn't exceeded
its services_per_page limit.
"""
org = None
if data.organization_id:
result = await db.execute(
select(Organization).where(Organization.id == data.organization_id)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization '{data.organization_id}' not found",
)
# Tier enforcement when org context is provided
if org is not None:
await enforce_service_limit(db, org)
service = Service(
name=data.name,
slug=data.slug,
@ -67,6 +102,7 @@ async def create_service(
group_name=data.group_name,
position=data.position,
is_visible=data.is_visible,
organization_id=data.organization_id,
)
db.add(service)
await db.flush()

View file

@ -1,17 +1,29 @@
"""Subscribers API endpoints."""
"""Subscribers API endpoints with tier enforcement.
When adding a subscriber to an organization, the org's subscriber limit
is checked against the org's tier.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, verify_api_key
from app.models.models import Subscriber
from app.models.saas_models import Organization
from app.services.tier_enforcement import enforce_subscriber_limit
router = APIRouter()
class SubscriberCreate(BaseModel):
email: str = Field(..., max_length=255)
organization_id: str | None = None # Optional: for tier enforcement
@router.get("/")
async def list_subscribers(db: AsyncSession = Depends(get_db)):
"""List all subscribers."""
@ -21,6 +33,7 @@ async def list_subscribers(db: AsyncSession = Depends(get_db)):
{
"id": s.id,
"email": s.email,
"organization_id": s.organization_id,
"is_confirmed": s.is_confirmed,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
@ -30,16 +43,36 @@ async def list_subscribers(db: AsyncSession = Depends(get_db)):
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_subscriber(
email: str,
data: SubscriberCreate,
db: AsyncSession = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
"""Add a new subscriber."""
import uuid
"""Add a new subscriber.
If organization_id is provided, tier enforcement is applied to ensure
the org hasn't exceeded its subscriber limit.
"""
org = None
if data.organization_id:
result = await db.execute(
select(Organization).where(Organization.id == data.organization_id)
)
org = result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization '{data.organization_id}' not found",
)
# Tier enforcement when org context is provided
if org is not None:
await enforce_subscriber_limit(db, org)
import uuid
subscriber = Subscriber(
email=email,
email=data.email,
confirm_token=str(uuid.uuid4()),
organization_id=data.organization_id,
)
db.add(subscriber)
await db.flush()
@ -47,6 +80,7 @@ async def create_subscriber(
return {
"id": subscriber.id,
"email": subscriber.email,
"organization_id": subscriber.organization_id,
"confirm_token": subscriber.confirm_token,
}