370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""
|
|
Workspace API routes wired through the canonical API composition root.
|
|
|
|
Only the core create/list/detail endpoints are enabled here. Member invitation
|
|
and subscription endpoints remain in application use cases but are mounted only
|
|
after their persistence repositories are wired and smoke-tested.
|
|
"""
|
|
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.dependencies import (
|
|
get_user_repository,
|
|
get_workspace_invitation_repository,
|
|
get_workspace_member_repository,
|
|
get_workspace_repository,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, EmailStr
|
|
|
|
from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase
|
|
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
|
|
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
|
|
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
|
|
from packages.application.workspace.list_workspaces_use_case import (
|
|
GetWorkspaceDetailRequest,
|
|
GetWorkspaceDetailUseCase,
|
|
ListWorkspacesRequest,
|
|
ListWorkspacesUseCase,
|
|
)
|
|
from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase
|
|
from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase
|
|
from packages.ports.user_repository import UserRepository
|
|
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
|
|
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
|
from packages.ports.workspace_repository import WorkspaceRepository
|
|
|
|
router = APIRouter(prefix="/workspaces", tags=["Workspaces"])
|
|
|
|
|
|
class CreateWorkspaceRequestModel(BaseModel):
|
|
name: str
|
|
subscription_plan: str = "free"
|
|
|
|
|
|
class WorkspaceResponseModel(BaseModel):
|
|
workspace_id: str
|
|
name: str
|
|
subscription_plan: str
|
|
max_projects: int
|
|
max_storage_gb: int
|
|
|
|
|
|
class WorkspaceSummaryModel(BaseModel):
|
|
workspace_id: str
|
|
name: str
|
|
subscription_plan: str
|
|
max_projects: int
|
|
max_storage_gb: int
|
|
member_count: int
|
|
user_role: str
|
|
|
|
|
|
class WorkspaceListResponseModel(BaseModel):
|
|
workspaces: list[WorkspaceSummaryModel]
|
|
|
|
|
|
class WorkspaceDetailResponseModel(BaseModel):
|
|
workspace_id: str
|
|
name: str
|
|
owner_user_id: str
|
|
subscription_plan: str
|
|
subscription_status: str
|
|
max_projects: int
|
|
max_storage_gb: int
|
|
used_storage_gb: float
|
|
member_count: int
|
|
user_role: str
|
|
|
|
|
|
class InviteMemberRequestModel(BaseModel):
|
|
email: EmailStr
|
|
role: str
|
|
|
|
|
|
class InviteMemberResponseModel(BaseModel):
|
|
invitation_id: str
|
|
invitee_email: str
|
|
role: str
|
|
expires_at: str
|
|
|
|
|
|
class MemberResponseModel(BaseModel):
|
|
member_id: str
|
|
user_id: str
|
|
username: str
|
|
email: str
|
|
display_name: str
|
|
role: str
|
|
invited_by: str | None
|
|
joined_at: str
|
|
|
|
|
|
class MemberListResponseModel(BaseModel):
|
|
members: list[MemberResponseModel]
|
|
|
|
|
|
class UpdateMemberRoleRequestModel(BaseModel):
|
|
role: str
|
|
|
|
|
|
class UpdateMemberRoleResponseModel(BaseModel):
|
|
user_id: str
|
|
old_role: str
|
|
new_role: str
|
|
|
|
|
|
class AcceptInvitationResponseModel(BaseModel):
|
|
workspace_id: str
|
|
workspace_name: str
|
|
role: str
|
|
|
|
|
|
class _NoopInvitationEmailService:
|
|
def send_workspace_invitation_email(self, **kwargs):
|
|
return False, "Email delivery is disabled for workspace compatibility routes"
|
|
|
|
|
|
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
|
|
async def create_workspace(
|
|
request: CreateWorkspaceRequestModel,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
user_repository: UserRepository = Depends(get_user_repository),
|
|
):
|
|
use_case = CreateWorkspaceUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
user_repository=user_repository,
|
|
)
|
|
response, error = use_case.execute(
|
|
CreateWorkspaceRequest(
|
|
name=request.name,
|
|
owner_user_id=authenticated_user.user.id,
|
|
subscription_plan=request.subscription_plan,
|
|
)
|
|
)
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace")
|
|
|
|
return WorkspaceResponseModel(
|
|
workspace_id=response.workspace_id,
|
|
name=response.name,
|
|
subscription_plan=response.subscription_plan,
|
|
max_projects=response.max_projects,
|
|
max_storage_gb=response.max_storage_gb,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=WorkspaceListResponseModel)
|
|
async def list_workspaces(
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
):
|
|
use_case = ListWorkspacesUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
)
|
|
response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id))
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces")
|
|
|
|
return WorkspaceListResponseModel(
|
|
workspaces=[
|
|
WorkspaceSummaryModel(
|
|
workspace_id=workspace.workspace_id,
|
|
name=workspace.name,
|
|
subscription_plan=workspace.subscription_plan,
|
|
max_projects=workspace.max_projects,
|
|
max_storage_gb=workspace.max_storage_gb,
|
|
member_count=workspace.member_count,
|
|
user_role=workspace.user_role,
|
|
)
|
|
for workspace in response.workspaces
|
|
]
|
|
)
|
|
|
|
|
|
@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel)
|
|
async def accept_invitation(
|
|
invitation_token: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
|
|
user_repository: UserRepository = Depends(get_user_repository),
|
|
):
|
|
response, error = AcceptInvitationUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
workspace_invitation_repository=workspace_invitation_repository,
|
|
user_repository=user_repository,
|
|
).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id))
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation")
|
|
|
|
return AcceptInvitationResponseModel(
|
|
workspace_id=response.workspace_id,
|
|
workspace_name=response.workspace_name,
|
|
role=response.role,
|
|
)
|
|
|
|
|
|
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
|
|
async def get_workspace_detail(
|
|
workspace_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
):
|
|
use_case = GetWorkspaceDetailUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
)
|
|
detail, error = use_case.execute(
|
|
GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id)
|
|
)
|
|
if error or detail is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied")
|
|
|
|
return WorkspaceDetailResponseModel(
|
|
workspace_id=detail.workspace_id,
|
|
name=detail.name,
|
|
owner_user_id=detail.owner_user_id,
|
|
subscription_plan=detail.subscription_plan,
|
|
subscription_status=detail.subscription_status,
|
|
max_projects=detail.max_projects,
|
|
max_storage_gb=detail.max_storage_gb,
|
|
used_storage_gb=detail.used_storage_gb,
|
|
member_count=detail.member_count,
|
|
user_role=detail.user_role,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def invite_member(
|
|
workspace_id: str,
|
|
request: InviteMemberRequestModel,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
|
|
user_repository: UserRepository = Depends(get_user_repository),
|
|
):
|
|
use_case = InviteMemberUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
workspace_invitation_repository=workspace_invitation_repository,
|
|
user_repository=user_repository,
|
|
email_service=_NoopInvitationEmailService(),
|
|
)
|
|
response, error = use_case.execute(
|
|
InviteMemberRequest(
|
|
workspace_id=workspace_id,
|
|
inviter_user_id=authenticated_user.user.id,
|
|
invitee_email=request.email,
|
|
role=request.role,
|
|
)
|
|
)
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member")
|
|
|
|
return InviteMemberResponseModel(
|
|
invitation_id=response.invitation_id,
|
|
invitee_email=response.invitee_email,
|
|
role=response.role,
|
|
expires_at=response.expires_at.isoformat(),
|
|
)
|
|
|
|
|
|
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
|
|
async def list_members(
|
|
workspace_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
user_repository: UserRepository = Depends(get_user_repository),
|
|
):
|
|
use_case = ListMembersUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
user_repository=user_repository,
|
|
)
|
|
response, error = use_case.execute(
|
|
ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id)
|
|
)
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members")
|
|
|
|
return MemberListResponseModel(
|
|
members=[
|
|
MemberResponseModel(
|
|
member_id=member.member_id,
|
|
user_id=member.user_id,
|
|
username=member.username,
|
|
email=member.email,
|
|
display_name=member.display_name,
|
|
role=member.role,
|
|
invited_by=member.invited_by,
|
|
joined_at=member.joined_at.isoformat(),
|
|
)
|
|
for member in response.members
|
|
]
|
|
)
|
|
|
|
|
|
@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel)
|
|
async def update_member_role(
|
|
workspace_id: str,
|
|
user_id: str,
|
|
request: UpdateMemberRoleRequestModel,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
):
|
|
use_case = UpdateMemberRoleUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
)
|
|
response, error = use_case.execute(
|
|
UpdateMemberRoleRequest(
|
|
workspace_id=workspace_id,
|
|
requester_user_id=authenticated_user.user.id,
|
|
target_user_id=user_id,
|
|
new_role=request.role,
|
|
)
|
|
)
|
|
if error or response is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role")
|
|
|
|
return UpdateMemberRoleResponseModel(
|
|
user_id=response.user_id,
|
|
old_role=response.old_role,
|
|
new_role=response.new_role,
|
|
)
|
|
|
|
|
|
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def remove_member(
|
|
workspace_id: str,
|
|
user_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
):
|
|
success, error = RemoveMemberUseCase(
|
|
workspace_repository=workspace_repository,
|
|
workspace_member_repository=workspace_member_repository,
|
|
).execute(
|
|
RemoveMemberRequest(
|
|
workspace_id=workspace_id,
|
|
requester_user_id=authenticated_user.user.id,
|
|
target_user_id=user_id,
|
|
)
|
|
)
|
|
if not success:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
|