"""superadmin/routers/authors.py — Author account management routes. Routes: GET /authors POST /authors GET /authors/{author_id} POST /authors/{author_id}/suspend POST /authors/{author_id}/unsuspend DELETE /authors/{author_id} POST /authors/{author_id}/grant GET /authors/{author_id}/embed-token """ from datetime import datetime, timezone import structlog from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.access.token_crypto import create_subscription_token from app.dependencies import get_db, get_current_superadmin, get_redis from app.models.book import Book from app.models.client_access import ClientAccess from app.models.user import User from app.repositories.access_repo import AccessRepository from app.repositories.audit_repo import AuditRepository from app.repositories.user_repo import UserRepository from app.schemas.superadmin import CreateAuthorRequest, GrantAccessRequest from app.services.auth_service import AuthService from app.services.author_onboarding_service import AuthorOnboardingService, author_onboarding_dict from app.services.superadmin_service import SuperAdminService from app.superadmin.routers._utils import _err log = structlog.get_logger(__name__) router = APIRouter() @router.post("/authors", status_code=201) async def create_author( payload: CreateAuthorRequest, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Create a new author account. Optionally assign a subscription plan.""" try: auth_svc = AuthService(db) result = await auth_svc.register( email=payload.email, password=payload.password, full_name=payload.full_name, ) author_id = result["author_slug"] user = await UserRepository(db).get_by_id(author_id) if user: user.provisioned_via = "superadmin" user.activation_status = "active" await db.commit() audit = AuditRepository(db) await audit.log( actor_id=superadmin.id, actor_email=superadmin.email, action="create_author", target_type="user", target_id=author_id, details=f"Created author {payload.email}", ) response = { "message": f"Author '{payload.full_name}' created successfully", "author_id": author_id, "email": payload.email, } if payload.plan: service = SuperAdminService(db, redis) grant_result = await service.grant_access( actor=superadmin, author_id=author_id, plan=payload.plan, auto_renew=payload.auto_renew, ) response["subscription"] = { "plan": payload.plan, "token": grant_result.get("token"), "expires_at": str(grant_result.get("expires_at", "")), } return response except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: return _err(e, "create_author") @router.get("/authors") async def list_authors( limit: int = Query(50, ge=1, le=500), cursor: str | None = Query(None), superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), ): """List all author accounts with subscription status.""" try: user_repo = UserRepository(db) users = await user_repo.list_all_authors(limit=limit, cursor=cursor) clients = [] for u in users: try: access_result = await db.execute( select(ClientAccess) .where(ClientAccess.author_id == u.id, ClientAccess.is_revoked == False) .order_by(ClientAccess.granted_at.desc()) .limit(1) ) access = access_result.scalar_one_or_none() book_count = await db.scalar( select(func.count()).where(Book.author_id == u.id) ) or 0 clients.append({ "id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role, "created_at": u.created_at.isoformat() if u.created_at else None, "chatbot_is_active": u.chatbot_is_active, "suspended": not u.chatbot_is_active, "book_count": book_count, "plan": access.plan if access else None, "token_budget": access.token_budget if access else 0, "expires_at": access.expires_at.isoformat() if access and access.expires_at else None, "granted_at": access.granted_at.isoformat() if access and access.granted_at else None, **author_onboarding_dict(u), }) except Exception as inner_e: log.warning("Error enriching author", author_id=u.id, error=str(inner_e)) clients.append({ "id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role, "created_at": None, "chatbot_is_active": True, "suspended": False, "book_count": 0, "plan": None, "token_budget": 0, "expires_at": None, "granted_at": None, }) return {"clients": clients, "count": len(clients)} except Exception as e: return _err(e, "list_authors") @router.get("/authors/{author_id}") async def get_author( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), ): """Get full detail for one author including active subscription.""" try: user = await UserRepository(db).get_by_id(author_id) if not user: raise HTTPException(404, "Author not found") access = await AccessRepository(db).get_active_for_author(author_id) return { "user": { "id": user.id, "email": user.email, "full_name": user.full_name, "website_url": user.website_url, "chatbot_is_active": user.chatbot_is_active, "created_at": user.created_at.isoformat() if user.created_at else None, **author_onboarding_dict(user), }, "access": { "grant_id": access.id, "plan": access.plan, "expires_at": access.expires_at.isoformat() if access.expires_at else None, "is_revoked": access.is_revoked, "token_budget": access.token_budget, "bonus_tokens": access.bonus_tokens, } if access else None, } except HTTPException: raise except Exception as e: return _err(e, "get_author") @router.post("/authors/{author_id}/suspend", status_code=200) async def suspend_author( author_id: str, reason: str = "", superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Suspend an author account immediately.""" try: await SuperAdminService(db, redis).suspend_author( actor=superadmin, author_id=author_id, reason=reason ) return {"message": "Author suspended"} except Exception as e: return _err(e, "suspend_author") @router.post("/authors/{author_id}/unsuspend", status_code=200) async def unsuspend_author( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Reinstate a suspended author account.""" try: await SuperAdminService(db, redis).unsuspend_author( actor=superadmin, author_id=author_id ) return {"message": "Author unsuspended"} except Exception as e: return _err(e, "unsuspend_author") @router.delete("/authors/{author_id}", status_code=200) async def delete_author( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Permanently delete an author account and all associated data.""" try: await SuperAdminService(db, redis).delete_author( actor=superadmin, author_id=author_id ) return {"message": "Author deleted"} except Exception as e: return _err(e, "delete_author") @router.post("/authors/{author_id}/grant", status_code=201) async def grant_access( author_id: str, payload: GrantAccessRequest, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Grant a subscription with token budget to an author.""" try: result = await SuperAdminService(db, redis).grant_access( actor=superadmin, author_id=author_id, plan=payload.plan, auto_renew=payload.auto_renew, notes=payload.notes, custom_token_budget=payload.custom_token_budget, ) return result except Exception as e: return _err(e, "grant_access") @router.get("/authors/{author_id}/embed-token") async def get_author_embed_token( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), ): """Get the active subscription token for an author (for embed script generation).""" try: user = await db.get(User, author_id) if not user: raise HTTPException(404, "Author not found") now = datetime.now(timezone.utc).replace(tzinfo=None) result = await db.execute( select(ClientAccess) .where( ClientAccess.author_id == author_id, ClientAccess.is_revoked == False, ClientAccess.expires_at > now, ) .order_by(ClientAccess.expires_at.desc()) .limit(1) ) access = result.scalar_one_or_none() if not access: raise HTTPException(404, "No active subscription found for this author") token = create_subscription_token( author_id=author_id, grant_id=access.id, granted_at=access.granted_at, expires_at=access.expires_at, ) return { "token": token, "grant_id": access.id, "plan": access.plan, "expires_at": access.expires_at.isoformat(), "author_slug": user.id, } except HTTPException: raise except Exception as e: return _err(e, "get_author_embed_token") @router.post("/authors/{author_id}/restore-grant", status_code=200) async def restore_revoked_grant( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Un-revoke the most recent in-term grant for an author (SuperAdmin override). Restores the most recently expiring grant that is revoked but still within its validity period. Clears any Redis blacklist entry for tokens pointing to that grant. Does NOT create a new grant — the existing embed HTML on the author's website continues to work without any changes. """ try: from datetime import datetime, timezone from app.core.access.subscription import REVOCATION_REDIS_PREFIX, _is_hard_revoke from app.core.access.token_crypto import create_subscription_token, hash_subscription_token user = await db.get(User, author_id) if not user: raise HTTPException(404, "Author not found") now = datetime.now(timezone.utc) result = await db.execute( select(ClientAccess) .where(ClientAccess.author_id == author_id) .order_by(ClientAccess.expires_at.desc()) ) grant = None for row in result.scalars(): exp = row.expires_at if exp is not None: if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) if exp <= now: continue grant = row break if grant is None: raise HTTPException(404, "No in-term grant found for this author") was_revoked = grant.is_revoked grant.is_revoked = False grant.revoked_at = None grant.revoke_reason = None if not user.chatbot_is_active: user.chatbot_is_active = True await db.commit() await db.refresh(grant) # Clear Redis blacklist for the deterministic token pointing to this grant try: probe = create_subscription_token( author_id=author_id, grant_id=grant.id, granted_at=grant.granted_at, expires_at=grant.expires_at, ) await redis.delete(f"{REVOCATION_REDIS_PREFIX}{hash_subscription_token(probe)}") except Exception: pass audit = AuditRepository(db) await audit.log( actor_id=superadmin.id, actor_email=superadmin.email, action="restore_grant", target_type="client_access", target_id=grant.id, details=f"Restored grant for {user.email} (was_revoked={was_revoked})", ) return { "message": "Grant restored — visitors with existing embed HTML will work immediately.", "grant_id": grant.id, "plan": grant.plan, "expires_at": grant.expires_at.isoformat() if grant.expires_at else None, "was_revoked": was_revoked, } except HTTPException: raise except Exception as e: return _err(e, "restore_grant") @router.post("/authors/{author_id}/resend-credentials", status_code=202) async def resend_author_credentials( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), ): """Queue a login email to an author (no password unless auto-provisioned).""" try: return await AuthorOnboardingService(db).resend_credentials_email(author_id) except HTTPException: raise except Exception as e: return _err(e, "resend_credentials") @router.post("/authors/{author_id}/generate-support-password", status_code=200) async def generate_support_password( author_id: str, superadmin=Depends(get_current_superadmin), db: AsyncSession = Depends(get_db), ): """Generate a one-time temp password, email it, return once to SuperAdmin.""" try: return await AuthorOnboardingService(db).generate_support_password(author_id) except HTTPException: raise except Exception as e: return _err(e, "generate_support_password")