diff --git a/apps/api/app/api/routes/workspaces.py b/apps/api/app/api/routes/workspaces.py index 6d353ab22..b8b917ef2 100644 --- a/apps/api/app/api/routes/workspaces.py +++ b/apps/api/app/api/routes/workspaces.py @@ -7,18 +7,29 @@ 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_member_repository, get_workspace_repository +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 +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 @@ -65,6 +76,54 @@ class WorkspaceDetailResponseModel(BaseModel): 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, @@ -127,6 +186,31 @@ async def list_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, @@ -156,3 +240,130 @@ async def get_workspace_detail( 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") diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 650c8fa3e..949d8aad3 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -23,9 +23,11 @@ from packages.adapters.sqlalchemy_impl.project_repository import ( ) from packages.adapters.sqlalchemy_impl.session import build_session_factory from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository +from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository 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 @@ -98,3 +100,9 @@ def get_workspace_member_repository( session: Session = Depends(get_db_session), ) -> WorkspaceMemberRepository: return SQLAlchemyWorkspaceMemberRepository(session) + + +def get_workspace_invitation_repository( + session: Session = Depends(get_db_session), +) -> WorkspaceInvitationRepository: + return SQLAlchemyWorkspaceInvitationRepository(session) diff --git a/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py b/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py new file mode 100644 index 000000000..9958be1da --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import WorkspaceInvitationModel +from packages.domain.entities import WorkspaceInvitation +from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository + + +class SQLAlchemyWorkspaceInvitationRepository(WorkspaceInvitationRepository): + def __init__(self, session: Session): + self.session = session + + def save(self, invitation: WorkspaceInvitation) -> None: + model = self.session.get(WorkspaceInvitationModel, invitation.id) + if model is None: + model = WorkspaceInvitationModel(id=invitation.id) + self.session.add(model) + + model.workspace_id = invitation.workspace_id + model.inviter_user_id = invitation.inviter_user_id + model.invitee_email = invitation.invitee_email.strip().lower() + model.role = str(invitation.role) + model.invitation_token = invitation.invitation_token + model.status = str(invitation.status) + model.expires_at = invitation.expires_at + model.accepted_at = invitation.accepted_at + model.created_at = invitation.created_at + + self.session.commit() + self.session.refresh(model) + + def find_by_id(self, invitation_id: str) -> WorkspaceInvitation | None: + return self._to_entity(self.session.get(WorkspaceInvitationModel, invitation_id)) + + def find_by_token(self, token: str) -> WorkspaceInvitation | None: + model = ( + self.session.query(WorkspaceInvitationModel) + .filter(WorkspaceInvitationModel.invitation_token == token) + .first() + ) + return self._to_entity(model) + + def find_pending_by_workspace_and_email(self, workspace_id: str, email: str) -> WorkspaceInvitation | None: + model = ( + self.session.query(WorkspaceInvitationModel) + .filter( + WorkspaceInvitationModel.workspace_id == workspace_id, + WorkspaceInvitationModel.invitee_email == email.strip().lower(), + WorkspaceInvitationModel.status == "pending", + ) + .first() + ) + return self._to_entity(model) + + def delete(self, invitation_id: str) -> bool: + model = self.session.get(WorkspaceInvitationModel, invitation_id) + if model is None: + return False + self.session.delete(model) + self.session.commit() + return True + + @staticmethod + def _to_entity(model: WorkspaceInvitationModel | None) -> WorkspaceInvitation | None: + if model is None: + return None + return WorkspaceInvitation( + id=model.id, + workspace_id=model.workspace_id, + inviter_user_id=model.inviter_user_id, + invitee_email=model.invitee_email, + role=model.role, + invitation_token=model.invitation_token, + status=model.status, + expires_at=model.expires_at, + accepted_at=model.accepted_at, + created_at=model.created_at, + ) diff --git a/packages/application/workspace/accept_invitation_use_case.py b/packages/application/workspace/accept_invitation_use_case.py index 5450d9da3..b2ab9ad3a 100644 --- a/packages/application/workspace/accept_invitation_use_case.py +++ b/packages/application/workspace/accept_invitation_use_case.py @@ -74,7 +74,10 @@ class AcceptInvitationUseCase: return None, f"Invitation has already been {invitation.status}" # 4. 检查是否过期 - if invitation.expires_at and datetime.now(timezone.utc) > invitation.expires_at: + expires_at = invitation.expires_at + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at and datetime.now(timezone.utc) > expires_at: # 更新状态为过期 invitation.status = InvitationStatus.EXPIRED self.workspace_invitation_repository.save(invitation) diff --git a/tests/unit/test_accept_invitation_use_case.py b/tests/unit/test_accept_invitation_use_case.py index 3c7bcc8f4..fb000e43f 100644 --- a/tests/unit/test_accept_invitation_use_case.py +++ b/tests/unit/test_accept_invitation_use_case.py @@ -137,6 +137,30 @@ class TestAcceptInvitationUseCase: assert valid_invitation.status == InvitationStatus.ACCEPTED assert valid_invitation.accepted_at is not None + def test_accept_invitation_success_with_naive_database_datetime( + self, + use_case, + mock_workspace_repo, + mock_member_repo, + mock_invitation_repo, + mock_user_repo, + test_workspace, + test_user, + valid_invitation, + ): + """测试数据库返回 naive datetime 时仍可接受邀请""" + valid_invitation.expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).replace(tzinfo=None) + mock_invitation_repo.find_by_token.return_value = valid_invitation + mock_user_repo.find_by_id.return_value = test_user + mock_workspace_repo.find_by_id.return_value = test_workspace + mock_member_repo.find_by_workspace_and_user.return_value = None + + response, error = use_case.execute(AcceptInvitationRequest(invitation_token="valid-token", user_id="user-123")) + + assert error is None + assert response is not None + assert response.workspace_id == "workspace-123" + def test_accept_invitation_invalid_token(self, use_case, mock_invitation_repo): """测试无效令牌""" mock_invitation_repo.find_by_token.return_value = None diff --git a/tests/unit/test_workspace_sqlalchemy_repositories.py b/tests/unit/test_workspace_sqlalchemy_repositories.py index 22b9adf12..e9500198b 100644 --- a/tests/unit/test_workspace_sqlalchemy_repositories.py +++ b/tests/unit/test_workspace_sqlalchemy_repositories.py @@ -3,19 +3,23 @@ from sqlalchemy.orm import sessionmaker from packages.adapters.sqlalchemy_impl.models import Base from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository +from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository 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 ListWorkspacesRequest, ListWorkspacesUseCase from packages.domain.auth import password_hasher from packages.domain.entities import User -def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories(): +def test_workspace_create_list_invite_and_members_with_sqlalchemy_repositories(): session = _sqlite_session() user_repository = SQLAlchemyUserRepository(session) workspace_repository = SQLAlchemyWorkspaceRepository(session) workspace_member_repository = SQLAlchemyWorkspaceMemberRepository(session) + workspace_invitation_repository = SQLAlchemyWorkspaceInvitationRepository(session) user_repository.save( User( id="user-1", @@ -48,6 +52,44 @@ def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories(): assert list_response.workspaces[0].user_role == "owner" assert list_response.workspaces[0].member_count == 1 + invite_response, invite_error = InviteMemberUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + workspace_invitation_repository=workspace_invitation_repository, + user_repository=user_repository, + email_service=_NoopEmailService(), + ).execute( + InviteMemberRequest( + workspace_id=create_response.workspace_id, + inviter_user_id="user-1", + invitee_email="Invitee@Example.com", + role="member", + ) + ) + + assert invite_error is None + assert invite_response is not None + assert invite_response.invitee_email == "invitee@example.com" + assert workspace_invitation_repository.find_pending_by_workspace_and_email( + create_response.workspace_id, "invitee@example.com" + ) + + members_response, members_error = ListMembersUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + user_repository=user_repository, + ).execute(ListMembersRequest(workspace_id=create_response.workspace_id, requester_user_id="user-1")) + + assert members_error is None + assert members_response is not None + assert len(members_response.members) == 1 + assert members_response.members[0].role == "owner" + + +class _NoopEmailService: + def send_workspace_invitation_email(self, **kwargs): + return False, "disabled" + def _sqlite_session(): engine = create_engine("sqlite:///:memory:")