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