"""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.", }