""" Workspace API — team workspaces with seat-based membership. Business plan includes 5 seats (models.subscription.PLANS); the seat limit is enforced here. Owners/admins manage members; glossaries can be shared with a workspace (glossaries.workspace_id). Endpoints: POST /api/v1/workspaces create (owner membership) GET /api/v1/workspaces list mine (with members) POST /api/v1/workspaces/{id}/members add member by email DELETE /api/v1/workspaces/{id}/members/{user_id} remove member """ from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from core.logging import get_logger from database.connection import get_sync_session from database.models import User, Workspace, WorkspaceMember from middleware.api_key_auth import get_authenticated_user from models.subscription import PlanType, PLANS logger = get_logger(__name__) router = APIRouter(prefix="/api/v1/workspaces", tags=["Workspaces"]) VALID_ROLES = {"admin", "member"} def _seat_limit_for_owner(plan) -> int: """Seats allowed for the workspace owner's plan (Business/Enterprise).""" try: plan_cfg = PLANS.get(PlanType(plan)) except (ValueError, TypeError): plan_cfg = None seats = (plan_cfg or {}).get("team_seats", 0) or 0 return seats if seats > 0 else 0 # -1 (enterprise) treated as unlimited def _seats_unlimited(plan) -> bool: try: return PlanType(plan) == PlanType.ENTERPRISE except (ValueError, TypeError): return False class WorkspaceCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) class MemberAdd(BaseModel): email: str = Field(..., min_length=3) role: str = Field("member", description="admin | member") @router.post("", status_code=201) async def create_workspace( payload: WorkspaceCreate, current_user=Depends(get_authenticated_user), ): if not current_user: raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"}) if not _seats_unlimited(current_user.plan) and _seat_limit_for_owner(current_user.plan) == 0: raise HTTPException( status_code=403, detail={ "error": "PLAN_REQUIRED", "message": "Les espaces de travail nécessitent le plan Business.", }, ) def _create(): with get_sync_session() as session: ws = Workspace(name=payload.name.strip(), owner_id=str(current_user.id)) session.add(ws) session.flush() session.add( WorkspaceMember( workspace_id=ws.id, user_id=str(current_user.id), role="owner", ) ) session.commit() session.refresh(ws) return ws.to_dict() from asyncio import to_thread data = await to_thread(_create) return JSONResponse(status_code=201, content={"data": data, "meta": {}}) @router.get("") async def list_workspaces(current_user=Depends(get_authenticated_user)): if not current_user: raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"}) def _list(): with get_sync_session() as session: memberships = ( session.query(WorkspaceMember) .filter(WorkspaceMember.user_id == str(current_user.id)) .all() ) result = [] for m in memberships: ws = ( session.query(Workspace) .filter(Workspace.id == m.workspace_id) .first() ) if not ws: continue members = ( session.query(WorkspaceMember) .filter(WorkspaceMember.workspace_id == ws.id) .all() ) enriched = { **ws.to_dict(), "my_role": m.role, "member_count": len(members), "seat_limit": ( -1 if _seats_unlimited(current_user.plan) else _seat_limit_for_owner(current_user.plan) ), } result.append(enriched) return result from asyncio import to_thread data = await to_thread(_list) return JSONResponse(status_code=200, content={"data": data, "meta": {}}) @router.post("/{workspace_id}/members", status_code=201) async def add_member( workspace_id: str, payload: MemberAdd, current_user=Depends(get_authenticated_user), ): if not current_user: raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"}) if payload.role not in VALID_ROLES: raise HTTPException( status_code=422, detail={"error": "INVALID_ROLE"} ) def _add(): with get_sync_session() as session: ws = ( session.query(Workspace) .filter(Workspace.id == workspace_id) .first() ) if not ws: return ("not_found", None) me = ( session.query(WorkspaceMember) .filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == str(current_user.id), ) .first() ) if not me or me.role not in ("owner", "admin"): return ("forbidden", None) # Seat enforcement (owner's plan) if ws.owner_id == str(current_user.id): owner_plan = current_user.plan else: owner = session.query(User).filter(User.id == ws.owner_id).first() owner_plan = getattr(owner, "plan", None) count = ( session.query(WorkspaceMember) .filter(WorkspaceMember.workspace_id == workspace_id) .count() ) if not _seats_unlimited(owner_plan) and count >= _seat_limit_for_owner(owner_plan): return ("seat_limit", count) target = ( session.query(User) .filter(User.email == payload.email.strip().lower()) .first() ) if not target: return ("user_not_found", None) existing = ( session.query(WorkspaceMember) .filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == str(target.id), ) .first() ) if existing: return ("already_member", None) member = WorkspaceMember( workspace_id=workspace_id, user_id=str(target.id), role=payload.role, ) session.add(member) session.commit() session.refresh(member) return ("ok", member.to_dict()) from asyncio import to_thread status, payload_out = await to_thread(_add) if status == "not_found": raise HTTPException(status_code=404, detail={"error": "WORKSPACE_NOT_FOUND"}) if status == "forbidden": raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"}) if status == "seat_limit": raise HTTPException( status_code=409, detail={ "error": "SEAT_LIMIT_REACHED", "message": "Limite de sièges du plan atteinte. Passez au plan supérieur pour ajouter des membres.", "details": {"seats_used": payload_out}, }, ) if status == "user_not_found": raise HTTPException( status_code=404, detail={ "error": "USER_NOT_FOUND", "message": "Aucun compte avec cet e-mail — l'utilisateur doit d'abord créer un compte.", }, ) if status == "already_member": raise HTTPException(status_code=409, detail={"error": "ALREADY_MEMBER"}) return JSONResponse(status_code=201, content={"data": payload_out, "meta": {}}) @router.delete("/{workspace_id}/members/{user_id}", status_code=200) async def remove_member( workspace_id: str, user_id: str, current_user=Depends(get_authenticated_user), ): if not current_user: raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"}) def _remove(): with get_sync_session() as session: me = ( session.query(WorkspaceMember) .filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == str(current_user.id), ) .first() ) if not me or me.role not in ("owner", "admin"): return "forbidden" target = ( session.query(WorkspaceMember) .filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == user_id, ) .first() ) if not target: return "not_found" if target.role == "owner": return "owner_immutable" session.delete(target) session.commit() return "ok" from asyncio import to_thread status = await to_thread(_remove) if status == "forbidden": raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"}) if status == "not_found": raise HTTPException(status_code=404, detail={"error": "MEMBER_NOT_FOUND"}) if status == "owner_immutable": raise HTTPException( status_code=422, detail={"error": "OWNER_IMMUTABLE", "message": "Le propriétaire ne peut pas être retiré."}, ) return JSONResponse( status_code=200, content={"data": {"removed": True}, "meta": {}} )