"""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 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 Service from app.models.saas_models import Organization from app.services.tier_enforcement import ( enforce_service_limit, get_org_if_provided, ) router = APIRouter() class ServiceCreate(BaseModel): name: str = Field(..., max_length=100) slug: str = Field(..., max_length=50, pattern=r"^[a-z0-9-]+$") description: str | None = None group_name: str | None = Field(None, max_length=50) position: int = 0 is_visible: bool = True organization_id: str | None = None class ServiceUpdate(BaseModel): name: str | None = None slug: str | None = Field(None, max_length=50, pattern=r"^[a-z0-9-]+$") description: str | None = None group_name: str | None = None position: int | None = None is_visible: bool | None = None def serialize_service(s: Service) -> dict: return { "id": s.id, "name": s.name, "slug": s.slug, "description": s.description, "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, } @router.get("/") async def list_services(db: AsyncSession = Depends(get_db)): """List all services.""" result = await db.execute(select(Service).order_by(Service.position, Service.name)) services = result.scalars().all() return [serialize_service(s) for s in services] @router.post("/", status_code=status.HTTP_201_CREATED) async def create_service( data: ServiceCreate, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """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, description=data.description, group_name=data.group_name, position=data.position, is_visible=data.is_visible, organization_id=data.organization_id, ) db.add(service) await db.flush() await db.refresh(service) return serialize_service(service) @router.get("/{service_id}") async def get_service(service_id: UUID, db: AsyncSession = Depends(get_db)): """Get a service by ID.""" result = await db.execute(select(Service).where(Service.id == str(service_id))) service = result.scalar_one_or_none() if not service: raise HTTPException(status_code=404, detail="Service not found") return serialize_service(service) @router.patch("/{service_id}") async def update_service( service_id: UUID, data: ServiceUpdate, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Update a service.""" result = await db.execute(select(Service).where(Service.id == str(service_id))) service = result.scalar_one_or_none() if not service: raise HTTPException(status_code=404, detail="Service not found") update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(service, field, value) await db.flush() await db.refresh(service) return serialize_service(service) @router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_service( service_id: UUID, db: AsyncSession = Depends(get_db), api_key: str = Depends(verify_api_key), ): """Delete a service.""" result = await db.execute(select(Service).where(Service.id == str(service_id))) service = result.scalar_one_or_none() if not service: raise HTTPException(status_code=404, detail="Service not found") await db.delete(service)