Arag / app /api /agency.py
AuthorBot
Add enterprise growth APIs, billing, admin UI, and test suite.
12930b5
Raw
History Blame Contribute Delete
8.44 kB
"""Author RAG — Agency Sub-Account Management API.
Routes (Agency owner only):
POST /api/admin/{slug}/agency/create → create agency account
GET /api/admin/{slug}/agency → agency info + member list
POST /api/admin/{slug}/agency/invite → invite author by email
DELETE /api/admin/{slug}/agency/members/{id} → remove member
POST /api/admin/{slug}/agency/impersonate/{id} → get temp JWT for child account
"""
import secrets
from datetime import datetime, timezone, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_author, get_db
from app.models.agency import AgencyAccount, AgencyMember
from app.models.user import User
from app.models.pricing_plan import PricingPlan
logger = structlog.get_logger(__name__)
router = APIRouter()
class AgencyCreate(BaseModel):
name: str = Field(..., max_length=200)
max_seats: int = Field(5, ge=2, le=50)
class InviteRequest(BaseModel):
email: EmailStr
role: str = Field("author", pattern="^(admin|author)$")
async def _require_agency_owner(slug: str, author, db: AsyncSession) -> AgencyAccount:
"""Verify author has Enterprise plan and return their agency (creating if needed)."""
plan = await db.get(PricingPlan, getattr(author, "stripe_plan_id", "") or "")
if not plan or not getattr(plan, "has_white_label", False):
raise HTTPException(403, "Agency accounts require Enterprise plan.")
result = await db.execute(select(AgencyAccount).where(AgencyAccount.owner_id == slug))
agency = result.scalar_one_or_none()
if not agency:
raise HTTPException(404, "No agency account found. Create one first.")
return agency
@router.post("/{slug}/agency/create", status_code=201)
async def create_agency(
slug: str, body: AgencyCreate,
db: AsyncSession = Depends(get_db),
author=Depends(get_current_author),
):
"""Create agency account (Enterprise plan required)."""
if author.id != slug: raise HTTPException(403, "Forbidden")
plan = await db.get(PricingPlan, getattr(author, "stripe_plan_id", "") or "")
if not plan or not getattr(plan, "has_white_label", False):
raise HTTPException(403, "Agency accounts require Enterprise plan.")
existing = await db.execute(select(AgencyAccount).where(AgencyAccount.owner_id == slug))
if existing.scalar_one_or_none():
raise HTTPException(409, "Agency account already exists.")
agency = AgencyAccount(owner_id=slug, name=body.name, max_seats=body.max_seats)
db.add(agency)
# Add owner as admin member
db.add(AgencyMember(
agency_id=agency.id, user_id=slug, role="admin",
accepted_at=datetime.now(timezone.utc),
))
await db.commit()
logger.info("Agency created", owner=slug, name=body.name)
return {"id": agency.id, "name": agency.name, "max_seats": agency.max_seats}
@router.get("/{slug}/agency")
async def get_agency(
slug: str, db: AsyncSession = Depends(get_db),
author=Depends(get_current_author),
):
"""Get agency info with member list."""
if author.id != slug: raise HTTPException(403, "Forbidden")
agency = await _require_agency_owner(slug, author, db)
members_r = await db.execute(
select(AgencyMember, User)
.join(User, AgencyMember.user_id == User.id)
.where(AgencyMember.agency_id == agency.id, AgencyMember.is_active == True) # noqa: E712
)
members = [
{"id": m.id, "user_id": m.user_id, "email": u.email,
"name": u.full_name, "role": m.role,
"joined_at": m.accepted_at.isoformat() if m.accepted_at else None}
for m, u in members_r
]
seats_used = len(members)
return {
"id": agency.id, "name": agency.name,
"max_seats": agency.max_seats, "seats_used": seats_used,
"seats_available": agency.max_seats - seats_used,
"members": members,
}
@router.post("/{slug}/agency/invite", status_code=201)
async def invite_member(
slug: str, body: InviteRequest,
db: AsyncSession = Depends(get_db),
author=Depends(get_current_author),
):
"""Invite an author by email to join the agency.
If user exists → add immediately.
If not → creates a pending invite token (user activates on first login).
"""
if author.id != slug: raise HTTPException(403, "Forbidden")
agency = await _require_agency_owner(slug, author, db)
# Check seats
members_r = await db.execute(
select(AgencyMember).where(
AgencyMember.agency_id == agency.id, AgencyMember.is_active == True # noqa: E712
)
)
if len(list(members_r.scalars())) >= agency.max_seats:
raise HTTPException(400, f"Agency seat limit ({agency.max_seats}) reached. Upgrade to add more seats.")
# Find existing user
user_r = await db.execute(select(User).where(User.email == body.email))
user = user_r.scalar_one_or_none()
if user:
existing = await db.execute(
select(AgencyMember).where(
AgencyMember.agency_id == agency.id, AgencyMember.user_id == user.id
)
)
if existing.scalar_one_or_none():
raise HTTPException(409, "User is already a member of this agency.")
member = AgencyMember(
agency_id=agency.id, user_id=user.id, role=body.role,
invited_by=slug, accepted_at=datetime.now(timezone.utc),
)
db.add(member)
await db.commit()
return {"status": "added", "email": body.email, "role": body.role}
else:
# Generate invite token — user registers and gets linked
token = secrets.token_urlsafe(32)
# Store token (Phase 4: persist in DB table)
logger.info("Agency invite sent", agency=agency.id, email=body.email, token=token[:8])
return {
"status": "invited",
"email": body.email,
"message": f"Invite sent to {body.email}. They'll be added on signup.",
}
@router.delete("/{slug}/agency/members/{member_id}")
async def remove_member(
slug: str, member_id: str,
db: AsyncSession = Depends(get_db),
author=Depends(get_current_author),
):
"""Remove a member from the agency."""
if author.id != slug: raise HTTPException(403, "Forbidden")
agency = await _require_agency_owner(slug, author, db)
member = await db.get(AgencyMember, member_id)
if not member or member.agency_id != agency.id:
raise HTTPException(404, "Member not found.")
if member.user_id == slug:
raise HTTPException(400, "Cannot remove agency owner.")
member.is_active = False
await db.commit()
return {"message": "Member removed from agency."}
@router.post("/{slug}/agency/impersonate/{target_user_id}")
async def impersonate_member(
slug: str, target_user_id: str,
db: AsyncSession = Depends(get_db),
author=Depends(get_current_author),
):
"""Generate a short-lived JWT for a child account (agency admin only).
Allows agency owner to access any member's admin dashboard without
knowing their password. Token expires in 1 hour.
"""
if author.id != slug: raise HTTPException(403, "Forbidden")
agency = await _require_agency_owner(slug, author, db)
member_r = await db.execute(
select(AgencyMember).where(
AgencyMember.agency_id == agency.id,
AgencyMember.user_id == target_user_id,
AgencyMember.is_active == True, # noqa: E712
)
)
if not member_r.scalar_one_or_none():
raise HTTPException(404, "Target user is not a member of your agency.")
target = await db.get(User, target_user_id)
if not target:
raise HTTPException(404, "User not found.")
from app.services.auth_service import create_access_token
token = create_access_token(
{"sub": target.id, "email": target.email,
"impersonated_by": slug, "scope": "impersonation"},
expires_delta=timedelta(hours=1),
)
logger.warning("Impersonation token issued", by=slug, target=target_user_id)
return {
"access_token": token,
"target_email": target.email,
"expires_in": 3600,
"warning": "This token grants full access to the target account. Do not share.",
}