"""Guest management routes — invite, list, delete guests (admin only).""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.auth import get_redis, hash_password from app.core.db import get_db from app.deps import get_current_user, require_admin from app.models.guest_user import GuestUser router = APIRouter(prefix="/api/v1/guests", tags=["guests"]) settings = get_settings() @router.post("/invite") async def invite_guest( request: Request, body: dict, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Invite a guest user. Admin only.""" email = body.get("email", "") name = body.get("name", "") expires_in_hours = body.get("expires_in_hours", 72) # Default 3 days if not email or not name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Email and name required", "code": "missing_fields"}, ) tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) # Check if guest already exists for this tenant existing_q = await db.execute( select(GuestUser) .where(GuestUser.email == email) .where(GuestUser.tenant_id == tenant_id) ) existing = existing_q.scalar_one_or_none() if existing: if existing.status == "active": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"detail": "Guest already active", "code": "guest_exists"}, ) # Re-invite: update existing record existing.name = name existing.status = "invited" existing.invited_by = user_id existing.expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours) existing.password_hash = None await db.commit() await db.refresh(existing) return { "id": str(existing.id), "email": existing.email, "name": existing.name, "status": existing.status, "expires_at": existing.expires_at.isoformat() if existing.expires_at else None, } expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours) guest = GuestUser( email=email, name=name, tenant_id=tenant_id, invited_by=user_id, status="invited", expires_at=expires_at, ) db.add(guest) await db.commit() await db.refresh(guest) return { "id": str(guest.id), "email": guest.email, "name": guest.name, "status": guest.status, "expires_at": guest.expires_at.isoformat() if guest.expires_at else None, } @router.post("/accept/{token}") async def accept_invitation( token: str, body: dict, db: AsyncSession = Depends(get_db), ): """Guest accepts invitation and sets password.""" password = body.get("password", "") if not password or len(password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Password must be at least 8 characters", "code": "weak_password"}, ) # Token is the guest user ID try: guest_id = uuid.UUID(token) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid token", "code": "invalid_token"}, ) guest_q = await db.execute( select(GuestUser).where(GuestUser.id == guest_id).where(GuestUser.status == "invited") ) guest = guest_q.scalar_one_or_none() if not guest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Invitation not found or already accepted", "code": "invitation_not_found"}, ) # Check expiration if guest.expires_at and guest.expires_at < datetime.now(UTC): guest.status = "expired" await db.commit() raise HTTPException( status_code=status.HTTP_410_GONE, detail={"detail": "Invitation expired", "code": "invitation_expired"}, ) guest.password_hash = hash_password(password) guest.status = "active" await db.commit() return {"message": "Invitation accepted", "status": "active"} @router.get("") async def list_guests( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """List all guest users for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(GuestUser) .where(GuestUser.tenant_id == tenant_id) .order_by(GuestUser.created_at.desc()) ) guests = result.scalars().all() return [ { "id": str(g.id), "email": g.email, "name": g.name, "status": g.status, "expires_at": g.expires_at.isoformat() if g.expires_at else None, "created_at": g.created_at.isoformat() if g.created_at else None, } for g in guests ] @router.delete("/{guest_id}") async def delete_guest( guest_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Delete/revoke a guest user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: gid = uuid.UUID(guest_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid guest ID", "code": "invalid_id"}, ) guest_q = await db.execute( select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_id) ) guest = guest_q.scalar_one_or_none() if not guest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Guest not found", "code": "not_found"}, ) # Revoke: mark as revoked and clear password guest.status = "revoked" guest.password_hash = None await db.commit() # Invalidate any active guest sessions redis = get_redis() # We can't easily find all sessions for this guest, but they'll expire naturally return {"message": "Guest revoked", "status": "revoked"}