From 709ae5ab73897fc9985b000f96bcef6c5d2aa13b Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:09:34 +0800 Subject: [PATCH 1/6] feat: add inbox messages module for calendar invitations --- backend/src/v1/inbox_messages/__init__.py | 0 backend/src/v1/inbox_messages/dependencies.py | 30 +++ backend/src/v1/inbox_messages/repository.py | 114 +++++++++++ backend/src/v1/inbox_messages/router.py | 43 +++++ backend/src/v1/inbox_messages/schemas.py | 46 +++++ backend/src/v1/inbox_messages/service.py | 181 ++++++++++++++++++ backend/src/v1/router.py | 2 + .../unit/v1/inbox_messages/test_schemas.py | 38 ++++ 8 files changed, 454 insertions(+) create mode 100644 backend/src/v1/inbox_messages/__init__.py create mode 100644 backend/src/v1/inbox_messages/dependencies.py create mode 100644 backend/src/v1/inbox_messages/repository.py create mode 100644 backend/src/v1/inbox_messages/router.py create mode 100644 backend/src/v1/inbox_messages/schemas.py create mode 100644 backend/src/v1/inbox_messages/service.py create mode 100644 backend/tests/unit/v1/inbox_messages/test_schemas.py diff --git a/backend/src/v1/inbox_messages/__init__.py b/backend/src/v1/inbox_messages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/inbox_messages/dependencies.py b/backend/src/v1/inbox_messages/dependencies.py new file mode 100644 index 0000000..d65ebb1 --- /dev/null +++ b/backend/src/v1/inbox_messages/dependencies.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db.session import get_db +from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository +from v1.inbox_messages.service import InboxMessageService +from v1.users.dependencies import get_current_user + + +async def get_inbox_message_repository( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SQLAlchemyInboxMessageRepository: + return SQLAlchemyInboxMessageRepository(session) + + +def get_inbox_message_service( + session: Annotated[AsyncSession, Depends(get_db)], + repository: Annotated[ + SQLAlchemyInboxMessageRepository, Depends(get_inbox_message_repository) + ], + user: Annotated[CurrentUser, Depends(get_current_user)], +) -> InboxMessageService: + return InboxMessageService( + repository=repository, session=session, current_user=user + ) diff --git a/backend/src/v1/inbox_messages/repository.py b/backend/src/v1/inbox_messages/repository.py new file mode 100644 index 0000000..1254d7f --- /dev/null +++ b/backend/src/v1/inbox_messages/repository.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.exc import SQLAlchemyError + +from core.logging import get_logger +from models.inbox_messages import InboxMessage + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.inbox_messages.repository") + + +class InboxMessageRepository(Protocol): + async def create(self, data: dict[str, object]) -> InboxMessage: ... + async def get_by_id( + self, message_id: UUID, recipient_id: UUID + ) -> InboxMessage | None: ... + async def list_by_recipient( + self, recipient_id: UUID, status: str | None = None + ) -> list[InboxMessage]: ... + async def update_status( + self, + message_id: UUID, + recipient_id: UUID, + status: str, + ) -> InboxMessage | None: ... + + +class SQLAlchemyInboxMessageRepository: + _session: AsyncSession + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, data: dict[str, object]) -> InboxMessage: + try: + message = InboxMessage(**data) + self._session.add(message) + await self._session.flush() + return message + except SQLAlchemyError: + logger.exception("Inbox message creation failed") + raise + + async def get_by_id( + self, message_id: UUID, recipient_id: UUID + ) -> InboxMessage | None: + try: + stmt = ( + select(InboxMessage) + .where(InboxMessage.id == message_id) + .where(InboxMessage.recipient_id == recipient_id) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception( + "Inbox message lookup failed", + message_id=str(message_id), + recipient_id=str(recipient_id), + ) + raise + + async def list_by_recipient( + self, recipient_id: UUID, status: str | None = None + ) -> list[InboxMessage]: + try: + stmt = ( + select(InboxMessage) + .where(InboxMessage.recipient_id == recipient_id) + .order_by(InboxMessage.created_at.desc()) + ) + if status is not None: + stmt = stmt.where(InboxMessage.status == status) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception( + "Inbox message list failed", + recipient_id=str(recipient_id), + status=status, + ) + raise + + async def update_status( + self, + message_id: UUID, + recipient_id: UUID, + status: str, + ) -> InboxMessage | None: + try: + stmt = ( + update(InboxMessage) + .where(InboxMessage.id == message_id) + .where(InboxMessage.recipient_id == recipient_id) + .values(status=status, is_read=True) + .returning(InboxMessage) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception( + "Inbox message status update failed", + message_id=str(message_id), + recipient_id=str(recipient_id), + status=status, + ) + raise diff --git a/backend/src/v1/inbox_messages/router.py b/backend/src/v1/inbox_messages/router.py new file mode 100644 index 0000000..b69ed99 --- /dev/null +++ b/backend/src/v1/inbox_messages/router.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from v1.inbox_messages.dependencies import get_inbox_message_service +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageListRequest, + InboxMessageResponse, + InboxMessageStatus, +) +from v1.inbox_messages.service import InboxMessageService + +router = APIRouter(prefix="/inbox/messages", tags=["inbox-messages"]) + + +@router.get("", response_model=list[InboxMessageResponse]) +async def list_inbox_messages( + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], + status: InboxMessageStatus | None = Query(default=None), +) -> list[InboxMessageResponse]: + request = InboxMessageListRequest(status=status) + return await service.list_messages(request) + + +@router.post("/{message_id}/accept", response_model=InboxMessageResponse) +async def accept_inbox_message( + message_id: UUID, + request: InboxMessageAcceptRequest, + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], +) -> InboxMessageResponse: + return await service.accept_invitation(message_id, request) + + +@router.post("/{message_id}/dismiss", response_model=InboxMessageResponse) +async def dismiss_inbox_message( + message_id: UUID, + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], +) -> InboxMessageResponse: + return await service.dismiss_invitation(message_id) diff --git a/backend/src/v1/inbox_messages/schemas.py b/backend/src/v1/inbox_messages/schemas.py new file mode 100644 index 0000000..d5b028b --- /dev/null +++ b/backend/src/v1/inbox_messages/schemas.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import ClassVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class InboxMessageType(str, Enum): + FRIEND_REQUEST = "friend_request" + CALENDAR = "calendar" + SYSTEM = "system" + GROUP = "group" + + +class InboxMessageStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + DISMISSED = "dismissed" + + +class InboxMessageResponse(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + id: UUID + recipient_id: UUID + sender_id: UUID | None = None + message_type: InboxMessageType + schedule_item_id: UUID | None = None + content: str | None = None + is_read: bool = False + status: InboxMessageStatus = InboxMessageStatus.PENDING + created_at: datetime + + +class InboxMessageListRequest(BaseModel): + status: InboxMessageStatus | None = None + + +class InboxMessageAcceptRequest(BaseModel): + permission_view: bool = True + permission_edit: bool = False + permission_invite: bool = False diff --git a/backend/src/v1/inbox_messages/service.py b/backend/src/v1/inbox_messages/service.py new file mode 100644 index 0000000..52061b0 --- /dev/null +++ b/backend/src/v1/inbox_messages/service.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.inbox_messages import InboxMessage +from models.schedule_subscriptions import ( + ScheduleSubscription, + SubscriptionStatus, +) +from v1.inbox_messages.repository import InboxMessageRepository +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageListRequest, + InboxMessageResponse, + InboxMessageStatus, + InboxMessageType, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.inbox_messages.service") + + +class InboxMessageService(BaseService): + _repository: InboxMessageRepository + _session: AsyncSession + + def __init__( + self, + repository: InboxMessageRepository, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def list_messages( + self, request: InboxMessageListRequest + ) -> list[InboxMessageResponse]: + user_id = self.require_user_id() + + try: + status = request.status.value if request.status else None + messages = await self._repository.list_by_recipient(user_id, status) + except SQLAlchemyError: + logger.exception("Failed to list inbox messages", user_id=str(user_id)) + raise HTTPException( + status_code=503, detail="Inbox message store unavailable" + ) + + return [self._to_response(message) for message in messages] + + async def accept_invitation( + self, + message_id: UUID, + request: InboxMessageAcceptRequest, + ) -> InboxMessageResponse: + user_id = self.require_user_id() + + try: + message = await self._repository.get_by_id(message_id, user_id) + if message is None: + raise HTTPException(status_code=404, detail="Inbox message not found") + if self._status_value(message.status) != InboxMessageStatus.PENDING.value: + raise HTTPException( + status_code=400, detail="Inbox message already handled" + ) + if ( + self._type_value(message.message_type) + != InboxMessageType.CALENDAR.value + or message.schedule_item_id is None + ): + raise HTTPException( + status_code=400, detail="Message is not a calendar invitation" + ) + + permission = self._encode_permission(request) + subscription = ScheduleSubscription( + item_id=message.schedule_item_id, + subscriber_id=user_id, + permission=permission, + status=SubscriptionStatus.ACTIVE, + created_by=user_id, + ) + self._session.add(subscription) + updated = await self._repository.update_status( + message_id, + user_id, + InboxMessageStatus.ACCEPTED.value, + ) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + logger.exception( + "Failed to accept inbox invitation", + message_id=str(message_id), + user_id=str(user_id), + ) + raise HTTPException( + status_code=503, detail="Inbox message store unavailable" + ) + + if updated is None: + raise HTTPException(status_code=404, detail="Inbox message not found") + return self._to_response(updated) + + async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse: + user_id = self.require_user_id() + + try: + message = await self._repository.get_by_id(message_id, user_id) + if message is None: + raise HTTPException(status_code=404, detail="Inbox message not found") + if self._status_value(message.status) != InboxMessageStatus.PENDING.value: + raise HTTPException( + status_code=400, detail="Inbox message already handled" + ) + + updated = await self._repository.update_status( + message_id, + user_id, + InboxMessageStatus.DISMISSED.value, + ) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + logger.exception( + "Failed to dismiss inbox invitation", + message_id=str(message_id), + user_id=str(user_id), + ) + raise HTTPException( + status_code=503, detail="Inbox message store unavailable" + ) + + if updated is None: + raise HTTPException(status_code=404, detail="Inbox message not found") + return self._to_response(updated) + + def _to_response(self, message: InboxMessage) -> InboxMessageResponse: + return InboxMessageResponse( + id=message.id, + recipient_id=message.recipient_id, + sender_id=message.sender_id, + message_type=InboxMessageType(self._type_value(message.message_type)), + schedule_item_id=message.schedule_item_id, + content=message.content, + is_read=bool(message.is_read), + status=InboxMessageStatus(self._status_value(message.status)), + created_at=message.created_at, + ) + + def _encode_permission(self, request: InboxMessageAcceptRequest) -> int: + permission = 0 + if request.permission_view: + permission |= 1 + if request.permission_edit: + permission |= 2 + if request.permission_invite: + permission |= 4 + return permission + + def _status_value(self, status: object) -> str: + if isinstance(status, Enum): + return str(status.value) + return str(status) + + def _type_value(self, message_type: object) -> str: + if isinstance(message_type, Enum): + return str(message_type.value) + return str(message_type) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 0ee1f42..f589311 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter from core.http.models import HealthResponse from v1.agent_chat.router import router as agent_chat_router from v1.auth.router import router as auth_router +from v1.inbox_messages.router import router as inbox_messages_router from v1.infra.router import router as infra_router from v1.schedule_items.router import router as schedule_items_router from v1.users.router import router as users_router @@ -16,6 +17,7 @@ router.include_router(infra_router) router.include_router(users_router) router.include_router(agent_chat_router) router.include_router(schedule_items_router) +router.include_router(inbox_messages_router) @router.get("/health", response_model=HealthResponse) diff --git a/backend/tests/unit/v1/inbox_messages/test_schemas.py b/backend/tests/unit/v1/inbox_messages/test_schemas.py new file mode 100644 index 0000000..d5a3ac8 --- /dev/null +++ b/backend/tests/unit/v1/inbox_messages/test_schemas.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageResponse, + InboxMessageStatus, + InboxMessageType, +) + + +def test_inbox_message_response_schema() -> None: + msg_id = uuid4() + response = InboxMessageResponse( + id=msg_id, + recipient_id=uuid4(), + sender_id=uuid4(), + message_type=InboxMessageType.CALENDAR, + schedule_item_id=uuid4(), + content="Join my calendar", + is_read=False, + status=InboxMessageStatus.PENDING, + created_at=datetime.now(timezone.utc), + ) + + assert response.message_type.value == "calendar" + assert response.status.value == "pending" + + +def test_inbox_message_accept_request_schema() -> None: + request = InboxMessageAcceptRequest( + permission_view=True, + permission_edit=False, + permission_invite=False, + ) + + assert request.permission_view is True + assert request.permission_edit is False From 7a49783156f7835fd688e58cac6e3af8dfc8ec22 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:15:59 +0800 Subject: [PATCH 2/6] feat: add share calendar API --- backend/src/v1/schedule_items/repository.py | 12 +++ backend/src/v1/schedule_items/router.py | 11 ++ backend/src/v1/schedule_items/schemas.py | 21 ++++ backend/src/v1/schedule_items/service.py | 60 ++++++++++- .../unit/v1/schedule_items/test_share.py | 101 ++++++++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 backend/tests/unit/v1/schedule_items/test_share.py diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index 1f3caa7..670154e 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -10,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository from core.logging import get_logger from models.schedule_items import ScheduleItem +from models.schedule_subscriptions import ScheduleSubscription if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +19,7 @@ logger = get_logger("v1.schedule_items.repository") class ScheduleItemRepository(Protocol): + async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ... async def get_by_item_id( self, item_id: UUID, owner_id: UUID ) -> ScheduleItem | None: ... @@ -31,6 +33,7 @@ class ScheduleItemRepository(Protocol): async def list_by_date_range( self, owner_id: UUID, start_at: datetime, end_at: datetime ) -> list[ScheduleItem]: ... + async def create_subscription(self, data: dict) -> ScheduleSubscription: ... class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): @@ -127,3 +130,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): except SQLAlchemyError: logger.exception("Schedule item list failed", owner_id=str(owner_id)) raise + + async def create_subscription(self, data: dict) -> ScheduleSubscription: + sub = ScheduleSubscription(**data) + self._session.add(sub) + await self._session.flush() + return sub + + async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: + return await super().get_by_id(entity_id) diff --git a/backend/src/v1/schedule_items/router.py b/backend/src/v1/schedule_items/router.py index 609c308..0ff63da 100644 --- a/backend/src/v1/schedule_items/router.py +++ b/backend/src/v1/schedule_items/router.py @@ -12,6 +12,8 @@ from v1.schedule_items.schemas import ( ScheduleItemListItem, ScheduleItemListRequest, ScheduleItemResponse, + ScheduleItemShareRequest, + ScheduleItemShareResponse, ScheduleItemUpdateRequest, ) from v1.schedule_items.service import ScheduleItemService @@ -72,3 +74,12 @@ async def delete_schedule_item( service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], ) -> None: await service.delete(item_id) + + +@router.post("/{item_id}/share", response_model=ScheduleItemShareResponse) +async def share_schedule_item( + item_id: UUID, + request: ScheduleItemShareRequest, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> ScheduleItemShareResponse: + return await service.share(item_id, request) diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index 677318e..150194a 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -96,3 +96,24 @@ class ScheduleItemListItem(BaseModel): class ScheduleItemListRequest(BaseModel): start_at: datetime end_at: datetime + + +class ScheduleItemShareRequest(BaseModel): + email: str = Field(..., description="Email of user to share with") + permission_view: bool = Field(True, description="Grant view permission") + permission_edit: bool = Field(False, description="Grant edit permission") + permission_invite: bool = Field(False, description="Grant invite permission") + + def _permission_value(self) -> int: + value = 0 + if self.permission_view: + value |= 1 + if self.permission_edit: + value |= 4 + if self.permission_invite: + value |= 2 + return value + + +class ScheduleItemShareResponse(BaseModel): + message: str diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 6d47066..89b0596 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import json +from typing import TYPE_CHECKING, Protocol from uuid import UUID from fastapi import HTTPException @@ -9,13 +10,17 @@ from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.logging import get_logger +from models.inbox_messages import InboxMessage, InboxMessageType from models.schedule_items import ScheduleItem +from v1.auth.gateway import SupabaseAuthGateway from v1.schedule_items.repository import ScheduleItemRepository from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, ScheduleItemListRequest, ScheduleItemMetadata, ScheduleItemResponse, + ScheduleItemShareRequest, + ScheduleItemShareResponse, ScheduleItemUpdateRequest, ScheduleItemSourceType, ScheduleItemStatus, @@ -24,22 +29,31 @@ from v1.schedule_items.schemas import ( if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession + from v1.auth.schemas import UserByEmailResponse + logger = get_logger("v1.schedule_items.service") +class AuthByEmailGateway(Protocol): + async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ... + + class ScheduleItemService(BaseService): _repository: ScheduleItemRepository _session: AsyncSession + _auth_gateway: AuthByEmailGateway def __init__( self, repository: ScheduleItemRepository, session: AsyncSession, current_user: CurrentUser | None, + auth_gateway: AuthByEmailGateway | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._session = session + self._auth_gateway = auth_gateway or SupabaseAuthGateway() async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: user_id = self.require_user_id() @@ -179,6 +193,50 @@ class ScheduleItemService(BaseService): return [self._to_response(item) for item in items] + async def share( + self, item_id: UUID, request: ScheduleItemShareRequest + ) -> ScheduleItemShareResponse: + user_id = self.require_user_id() + + try: + item = await self._repository.get_by_id(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + if item.owner_id != user_id: + raise HTTPException( + status_code=403, + detail="Only owner can share this schedule item", + ) + + target_user = await self._auth_gateway.get_user_by_email(request.email) + recipient_id = UUID(target_user.id) + message = InboxMessage( + recipient_id=recipient_id, + sender_id=user_id, + message_type=InboxMessageType.CALENDAR, + schedule_item_id=item.id, + content=json.dumps({"permission": request._permission_value()}), + created_by=user_id, + ) + self._session.add(message) + await self._session.commit() + except HTTPException: + raise + except SQLAlchemyError: + await self._session.rollback() + logger.exception("Failed to share schedule item", item_id=str(item_id)) + raise HTTPException( + status_code=503, detail="Schedule item store unavailable" + ) + except ValueError: + await self._session.rollback() + logger.exception( + "Auth lookup returned invalid user id", email=request.email + ) + raise HTTPException(status_code=503, detail="Auth lookup unavailable") + + return ScheduleItemShareResponse(message="Calendar invitation sent") + def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse: return ScheduleItemResponse( id=item.id, diff --git a/backend/tests/unit/v1/schedule_items/test_share.py b/backend/tests/unit/v1/schedule_items/test_share.py new file mode 100644 index 0000000..55aa034 --- /dev/null +++ b/backend/tests/unit/v1/schedule_items/test_share.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import cast +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException + +from core.auth.models import CurrentUser +from models.schedule_items import ScheduleItem +from v1.auth.schemas import UserByEmailResponse +from v1.schedule_items.repository import ScheduleItemRepository +from v1.schedule_items.schemas import ScheduleItemShareRequest +from v1.schedule_items.service import ScheduleItemService + + +def test_share_request_schema() -> None: + request = ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=True, + permission_invite=False, + ) + assert request.email == "friend@example.com" + assert request.permission_view is True + + +def test_permission_bits_calculation() -> None: + request = ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=True, + permission_invite=False, + ) + assert request._permission_value() == 5 + + +def _build_item(item_id: UUID, owner_id: UUID) -> ScheduleItem: + item = MagicMock(spec=ScheduleItem) + item.id = item_id + item.owner_id = owner_id + item.title = "test" + item.description = None + item.start_at = datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc) + item.end_at = None + item.timezone = "UTC" + item.extra_metadata = {} + item.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc) + item.updated_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc) + return item + + +class ShareRepo: + def __init__(self, item: ScheduleItem | None) -> None: + self._item = item + + async def get_by_id(self, item_id: UUID) -> ScheduleItem | None: + if self._item and self._item.id == item_id: + return self._item + return None + + +class AuthGatewayStub: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: + return UserByEmailResponse( + id="00000000-0000-0000-0000-000000000222", + email=email, + created_at="2026-02-28T10:00:00Z", + email_confirmed_at=None, + ) + + +@pytest.mark.asyncio +async def test_share_forbidden_when_not_owner() -> None: + owner_id = UUID("00000000-0000-0000-0000-000000000001") + requester_id = UUID("00000000-0000-0000-0000-000000000002") + item_id = uuid4() + service = ScheduleItemService( + repository=cast( + ScheduleItemRepository, + ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)), + ), + session=AsyncMock(), + current_user=CurrentUser(id=requester_id), + auth_gateway=AuthGatewayStub(), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.share( + item_id, + ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=False, + permission_invite=False, + ), + ) + + assert exc_info.value.status_code == 403 From 173d91086f18eb2a5f28c415838337fb38fea834 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:28:45 +0800 Subject: [PATCH 3/6] test: add calendar sharing tests and update API docs --- .../integration/test_inbox_messages_routes.py | 156 ++++ .../integration/test_schedule_share_routes.py | 68 ++ .../unit/v1/inbox_messages/test_repository.py | 88 +++ .../unit/v1/inbox_messages/test_service.py | 180 +++++ .../unit/v1/schedule_items/test_share.py | 136 ++++ ...6-02-28-calendar-sharing-implementation.md | 714 ++++++++++++++++++ docs/runtime/runtime-route.md | 109 +++ 7 files changed, 1451 insertions(+) create mode 100644 backend/tests/integration/test_inbox_messages_routes.py create mode 100644 backend/tests/integration/test_schedule_share_routes.py create mode 100644 backend/tests/unit/v1/inbox_messages/test_repository.py create mode 100644 backend/tests/unit/v1/inbox_messages/test_service.py create mode 100644 docs/plans/2026-02-28-calendar-sharing-implementation.md diff --git a/backend/tests/integration/test_inbox_messages_routes.py b/backend/tests/integration/test_inbox_messages_routes.py new file mode 100644 index 0000000..2950a70 --- /dev/null +++ b/backend/tests/integration/test_inbox_messages_routes.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Callable +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from v1.inbox_messages.dependencies import get_inbox_message_service +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageListRequest, + InboxMessageResponse, + InboxMessageStatus, + InboxMessageType, +) +from v1.inbox_messages.service import InboxMessageService + + +class FakeInboxMessageService: + def __init__( + self, + messages: list[InboxMessageResponse], + accepted: InboxMessageResponse, + dismissed: InboxMessageResponse, + ) -> None: + self._messages = messages + self._accepted = accepted + self._dismissed = dismissed + + async def list_messages( + self, request: InboxMessageListRequest + ) -> list[InboxMessageResponse]: + if request.status is None: + return self._messages + return [ + message for message in self._messages if message.status == request.status + ] + + async def accept_invitation( + self, + message_id: UUID, + request: InboxMessageAcceptRequest, + ) -> InboxMessageResponse: + if message_id != self._accepted.id: + raise HTTPException(status_code=404, detail="Inbox message not found") + if not request.permission_view: + raise HTTPException(status_code=400, detail="permission_view is required") + return self._accepted + + async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse: + if message_id != self._dismissed.id: + raise HTTPException(status_code=404, detail="Inbox message not found") + return self._dismissed + + +def _override_inbox_message_service( + service: FakeInboxMessageService, +) -> Callable[[], InboxMessageService]: + def _get_service() -> InboxMessageService: + return service # type: ignore[return-value] + + return _get_service + + +def _build_message( + message_id: UUID, + status: InboxMessageStatus, +) -> InboxMessageResponse: + return InboxMessageResponse( + id=message_id, + recipient_id=uuid4(), + sender_id=uuid4(), + message_type=InboxMessageType.CALENDAR, + schedule_item_id=uuid4(), + content='{"permission": 1}', + is_read=False, + status=status, + created_at=datetime(2026, 2, 28, 9, 0, 0, tzinfo=timezone.utc), + ) + + +def test_list_inbox_messages_returns_200() -> None: + pending_message = _build_message(uuid4(), InboxMessageStatus.PENDING) + accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED) + service = FakeInboxMessageService( + messages=[pending_message, accepted_message], + accepted=accepted_message, + dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED), + ) + app.dependency_overrides[get_inbox_message_service] = ( + _override_inbox_message_service(service) + ) + + client = TestClient(app) + try: + response = client.get("/api/v1/inbox/messages", params={"status": "pending"}) + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["status"] == "pending" + finally: + app.dependency_overrides = {} + + +def test_accept_inbox_message_returns_200() -> None: + accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED) + service = FakeInboxMessageService( + messages=[accepted_message], + accepted=accepted_message, + dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED), + ) + app.dependency_overrides[get_inbox_message_service] = ( + _override_inbox_message_service(service) + ) + + client = TestClient(app) + try: + response = client.post( + f"/api/v1/inbox/messages/{accepted_message.id}/accept", + json={ + "permission_view": True, + "permission_edit": True, + "permission_invite": False, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["id"] == str(accepted_message.id) + assert body["status"] == "accepted" + finally: + app.dependency_overrides = {} + + +def test_dismiss_inbox_message_returns_200() -> None: + dismissed_message = _build_message(uuid4(), InboxMessageStatus.DISMISSED) + service = FakeInboxMessageService( + messages=[dismissed_message], + accepted=_build_message(uuid4(), InboxMessageStatus.ACCEPTED), + dismissed=dismissed_message, + ) + app.dependency_overrides[get_inbox_message_service] = ( + _override_inbox_message_service(service) + ) + + client = TestClient(app) + try: + response = client.post(f"/api/v1/inbox/messages/{dismissed_message.id}/dismiss") + assert response.status_code == 200 + body = response.json() + assert body["id"] == str(dismissed_message.id) + assert body["status"] == "dismissed" + finally: + app.dependency_overrides = {} diff --git a/backend/tests/integration/test_schedule_share_routes.py b/backend/tests/integration/test_schedule_share_routes.py new file mode 100644 index 0000000..6a42d30 --- /dev/null +++ b/backend/tests/integration/test_schedule_share_routes.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Callable +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from v1.schedule_items.dependencies import get_schedule_item_service +from v1.schedule_items.schemas import ( + ScheduleItemShareRequest, + ScheduleItemShareResponse, +) +from v1.schedule_items.service import ScheduleItemService + + +class FakeScheduleItemShareService: + def __init__(self, item_id: UUID) -> None: + self._item_id = item_id + self.last_share_request: ScheduleItemShareRequest | None = None + + async def share( + self, + item_id: UUID, + request: ScheduleItemShareRequest, + ) -> ScheduleItemShareResponse: + if item_id != self._item_id: + raise HTTPException(status_code=404, detail="Schedule item not found") + self.last_share_request = request + return ScheduleItemShareResponse(message="Calendar invitation sent") + + +def _override_schedule_item_service( + service: FakeScheduleItemShareService, +) -> Callable[[], ScheduleItemService]: + def _get_service() -> ScheduleItemService: + return service # type: ignore[return-value] + + return _get_service + + +def test_share_schedule_item_returns_200() -> None: + item_id = uuid4() + service = FakeScheduleItemShareService(item_id=item_id) + app.dependency_overrides[get_schedule_item_service] = ( + _override_schedule_item_service(service) + ) + + client = TestClient(app) + try: + response = client.post( + f"/api/v1/schedule-items/{item_id}/share", + json={ + "email": "friend@example.com", + "permission_view": True, + "permission_edit": False, + "permission_invite": True, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["message"] == "Calendar invitation sent" + assert service.last_share_request is not None + assert service.last_share_request.email == "friend@example.com" + assert service.last_share_request.permission_invite is True + finally: + app.dependency_overrides = {} diff --git a/backend/tests/unit/v1/inbox_messages/test_repository.py b/backend/tests/unit/v1/inbox_messages/test_repository.py new file mode 100644 index 0000000..a8781a4 --- /dev/null +++ b/backend/tests/unit/v1/inbox_messages/test_repository.py @@ -0,0 +1,88 @@ +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import SQLAlchemyError + +from models.inbox_messages import InboxMessageType +from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository + + +@pytest.mark.asyncio +async def test_create_adds_message_and_flushes() -> None: + session = AsyncMock() + session.add = MagicMock() + repository = SQLAlchemyInboxMessageRepository(session) + recipient_id = uuid4() + + result = await repository.create( + { + "recipient_id": recipient_id, + "sender_id": uuid4(), + "message_type": InboxMessageType.CALENDAR, + "schedule_item_id": uuid4(), + "content": "invite", + "created_by": uuid4(), + } + ) + + session.add.assert_called_once_with(result) + session.flush.assert_awaited_once() + assert result.recipient_id == recipient_id + + +@pytest.mark.asyncio +async def test_get_by_id_returns_message_when_exists() -> None: + session = AsyncMock() + repository = SQLAlchemyInboxMessageRepository(session) + expected = MagicMock() + execute_result = MagicMock() + execute_result.scalar_one_or_none.return_value = expected + session.execute.return_value = execute_result + + result = await repository.get_by_id(uuid4(), uuid4()) + + assert result is expected + session.execute.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_list_by_recipient_returns_messages() -> None: + session = AsyncMock() + repository = SQLAlchemyInboxMessageRepository(session) + message_one = MagicMock() + message_two = MagicMock() + execute_result = MagicMock() + execute_result.scalars.return_value.all.return_value = [message_one, message_two] + session.execute.return_value = execute_result + + result = await repository.list_by_recipient(uuid4(), "pending") + + assert result == [message_one, message_two] + session.execute.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_status_returns_updated_message_and_flushes() -> None: + session = AsyncMock() + repository = SQLAlchemyInboxMessageRepository(session) + updated = MagicMock() + execute_result = MagicMock() + execute_result.scalar_one_or_none.return_value = updated + session.execute.return_value = execute_result + + result = await repository.update_status(uuid4(), uuid4(), "dismissed") + + assert result is updated + session.execute.assert_awaited_once() + session.flush.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_by_id_propagates_sqlalchemy_error() -> None: + session = AsyncMock() + repository = SQLAlchemyInboxMessageRepository(session) + session.execute.side_effect = SQLAlchemyError("boom") + + with pytest.raises(SQLAlchemyError): + await repository.get_by_id(uuid4(), uuid4()) diff --git a/backend/tests/unit/v1/inbox_messages/test_service.py b/backend/tests/unit/v1/inbox_messages/test_service.py new file mode 100644 index 0000000..a2e136d --- /dev/null +++ b/backend/tests/unit/v1/inbox_messages/test_service.py @@ -0,0 +1,180 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException + +from core.auth.models import CurrentUser +from models.inbox_messages import ( + InboxMessage, + InboxMessageStatus as InboxMessageModelStatus, + InboxMessageType as InboxMessageModelType, +) +from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus +from v1.inbox_messages.schemas import InboxMessageAcceptRequest, InboxMessageListRequest +from v1.inbox_messages.service import InboxMessageService + + +def _build_message( + *, + message_id: UUID, + recipient_id: UUID, + status: InboxMessageModelStatus = InboxMessageModelStatus.PENDING, + message_type: InboxMessageModelType = InboxMessageModelType.CALENDAR, + schedule_item_id: UUID | None = None, +) -> InboxMessage: + message = MagicMock(spec=InboxMessage) + message.id = message_id + message.recipient_id = recipient_id + message.sender_id = uuid4() + message.message_type = message_type + message.schedule_item_id = schedule_item_id + message.content = "calendar invite" + message.is_read = False + message.status = status + message.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc) + return message + + +@pytest.mark.asyncio +async def test_list_messages_returns_messages() -> None: + user_id = uuid4() + repo = AsyncMock() + repo.list_by_recipient.return_value = [ + _build_message( + message_id=uuid4(), + recipient_id=user_id, + schedule_item_id=uuid4(), + ) + ] + session = AsyncMock() + service = InboxMessageService( + repository=repo, + session=session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.list_messages(InboxMessageListRequest()) + + assert len(result) == 1 + assert result[0].recipient_id == user_id + assert result[0].status.value == "pending" + repo.list_by_recipient.assert_awaited_once_with(user_id, None) + + +@pytest.mark.asyncio +async def test_accept_invitation_creates_subscription() -> None: + user_id = uuid4() + message_id = uuid4() + item_id = uuid4() + pending_message = _build_message( + message_id=message_id, + recipient_id=user_id, + schedule_item_id=item_id, + ) + accepted_message = _build_message( + message_id=message_id, + recipient_id=user_id, + status=InboxMessageModelStatus.ACCEPTED, + schedule_item_id=item_id, + ) + + repo = AsyncMock() + repo.get_by_id.return_value = pending_message + repo.update_status.return_value = accepted_message + + session = AsyncMock() + session.add = MagicMock() + + service = InboxMessageService( + repository=repo, + session=session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.accept_invitation( + message_id, + InboxMessageAcceptRequest( + permission_view=True, + permission_edit=True, + permission_invite=False, + ), + ) + + session.add.assert_called_once() + subscription = session.add.call_args.args[0] + assert isinstance(subscription, ScheduleSubscription) + assert subscription.item_id == item_id + assert subscription.subscriber_id == user_id + assert subscription.permission == 3 + assert subscription.status == SubscriptionStatus.ACTIVE + repo.update_status.assert_awaited_once_with(message_id, user_id, "accepted") + session.commit.assert_awaited_once() + assert result.status.value == "accepted" + + +@pytest.mark.asyncio +async def test_dismiss_invitation_updates_status() -> None: + user_id = uuid4() + message_id = uuid4() + pending_message = _build_message( + message_id=message_id, + recipient_id=user_id, + schedule_item_id=uuid4(), + ) + dismissed_message = _build_message( + message_id=message_id, + recipient_id=user_id, + status=InboxMessageModelStatus.DISMISSED, + schedule_item_id=uuid4(), + ) + + repo = AsyncMock() + repo.get_by_id.return_value = pending_message + repo.update_status.return_value = dismissed_message + + session = AsyncMock() + service = InboxMessageService( + repository=repo, + session=session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.dismiss_invitation(message_id) + + repo.update_status.assert_awaited_once_with(message_id, user_id, "dismissed") + session.commit.assert_awaited_once() + assert result.status.value == "dismissed" + + +@pytest.mark.asyncio +async def test_accept_noncalendar_message_fails() -> None: + user_id = uuid4() + message_id = uuid4() + non_calendar_message = _build_message( + message_id=message_id, + recipient_id=user_id, + message_type=InboxMessageModelType.FRIEND_REQUEST, + schedule_item_id=None, + ) + + repo = AsyncMock() + repo.get_by_id.return_value = non_calendar_message + + session = AsyncMock() + session.add = MagicMock() + + service = InboxMessageService( + repository=repo, + session=session, + current_user=CurrentUser(id=user_id), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.accept_invitation(message_id, InboxMessageAcceptRequest()) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Message is not a calendar invitation" + session.add.assert_not_called() + session.commit.assert_not_awaited() diff --git a/backend/tests/unit/v1/schedule_items/test_share.py b/backend/tests/unit/v1/schedule_items/test_share.py index 55aa034..7cb5a45 100644 --- a/backend/tests/unit/v1/schedule_items/test_share.py +++ b/backend/tests/unit/v1/schedule_items/test_share.py @@ -7,8 +7,10 @@ from uuid import UUID, uuid4 import pytest from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser +from models.inbox_messages import InboxMessage, InboxMessageType from models.schedule_items import ScheduleItem from v1.auth.schemas import UserByEmailResponse from v1.schedule_items.repository import ScheduleItemRepository @@ -72,6 +74,16 @@ class AuthGatewayStub: ) +class AuthGatewayInvalidIdStub: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: + return UserByEmailResponse( + id="not-a-uuid", + email=email, + created_at="2026-02-28T10:00:00Z", + email_confirmed_at=None, + ) + + @pytest.mark.asyncio async def test_share_forbidden_when_not_owner() -> None: owner_id = UUID("00000000-0000-0000-0000-000000000001") @@ -99,3 +111,127 @@ async def test_share_forbidden_when_not_owner() -> None: ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_share_success_creates_calendar_invitation_message() -> None: + owner_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + session = AsyncMock() + session.add = MagicMock() + service = ScheduleItemService( + repository=cast( + ScheduleItemRepository, + ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)), + ), + session=session, + current_user=CurrentUser(id=owner_id), + auth_gateway=AuthGatewayStub(), + ) + + result = await service.share( + item_id, + ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=True, + permission_invite=False, + ), + ) + + assert result.message == "Calendar invitation sent" + session.add.assert_called_once() + message = session.add.call_args.args[0] + assert isinstance(message, InboxMessage) + assert message.sender_id == owner_id + assert message.schedule_item_id == item_id + assert message.message_type == InboxMessageType.CALENDAR + assert message.content == '{"permission": 5}' + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_share_returns_not_found_when_item_missing() -> None: + requester_id = UUID("00000000-0000-0000-0000-000000000002") + service = ScheduleItemService( + repository=cast(ScheduleItemRepository, ShareRepo(None)), + session=AsyncMock(), + current_user=CurrentUser(id=requester_id), + auth_gateway=AuthGatewayStub(), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.share( + uuid4(), + ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=False, + permission_invite=False, + ), + ) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_share_invalid_auth_user_id_returns_503() -> None: + owner_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + session = AsyncMock() + service = ScheduleItemService( + repository=cast( + ScheduleItemRepository, + ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)), + ), + session=session, + current_user=CurrentUser(id=owner_id), + auth_gateway=AuthGatewayInvalidIdStub(), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.share( + item_id, + ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=False, + permission_invite=False, + ), + ) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Auth lookup unavailable" + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_share_sqlalchemy_error_rolls_back() -> None: + owner_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + session = AsyncMock() + session.add = MagicMock(side_effect=SQLAlchemyError("db error")) + service = ScheduleItemService( + repository=cast( + ScheduleItemRepository, + ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)), + ), + session=session, + current_user=CurrentUser(id=owner_id), + auth_gateway=AuthGatewayStub(), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.share( + item_id, + ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=False, + permission_invite=False, + ), + ) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Schedule item store unavailable" + session.rollback.assert_awaited_once() diff --git a/docs/plans/2026-02-28-calendar-sharing-implementation.md b/docs/plans/2026-02-28-calendar-sharing-implementation.md new file mode 100644 index 0000000..14baea5 --- /dev/null +++ b/docs/plans/2026-02-28-calendar-sharing-implementation.md @@ -0,0 +1,714 @@ +# Calendar Sharing Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现日历事件分享功能 - 用户可以分享日历事件给其他人(通过邮箱),被邀请人会收到待办消息,可以同意或忽略邀请。 + +**Architecture:** 使用现有的 schemas/repository/service/router 分层架构。新增 inbox_messages 模块处理邀请消息。复用 auth gateway 的 get_user_by_email 通过邮箱查找用户。 + +**Tech Stack:** FastAPI, SQLAlchemy (async), Pydantic, Supabase Auth + +--- + +## Permission Bits (from design doc) + +| Permission | Value | Binary | +|------------|-------|--------| +| view | 1 | 001 | +| invite | 2 | 010 | +| edit | 4 | 100 | + +- Owner has all permissions: 7 (111) +- Check permission: `permission & 2 == 2` (has invite) +- Add permission: `permission | 2` + +--- + +## Task 1: Add inbox_messages module (schemas, repository, service, router) + +**Files:** +- Create: `backend/src/v1/inbox_messages/__init__.py` +- Create: `backend/src/v1/inbox_messages/schemas.py` +- Create: `backend/src/v1/inbox_messages/repository.py` +- Create: `backend/src/v1/inbox_messages/service.py` +- Create: `backend/src/v1/inbox_messages/router.py` +- Modify: `backend/src/v1/router.py` - include inbox_messages router + +**Step 1: Write the failing test** + +```python +# backend/tests/unit/v1/inbox_messages/test_schemas.py +import pytest +from uuid import uuid4 +from v1.inbox_messages.schemas import ( + InboxMessageResponse, + InboxMessageListRequest, + InboxMessageAcceptRequest, +) + +def test_inbox_message_response_schema(): + msg_id = uuid4() + response = InboxMessageResponse( + id=msg_id, + recipient_id=uuid4(), + sender_id=uuid4(), + message_type="calendar", + schedule_item_id=uuid4(), + content="Join my calendar", + is_read=False, + status="pending", + ) + assert response.message_type == "calendar" + assert response.status == "pending" + +def test_inbox_message_accept_request_schema(): + request = InboxMessageAcceptRequest( + permission_view=True, + permission_edit=False, + permission_invite=False, + ) + assert request.permission_view is True + assert request.permission_edit is False +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'v1.inbox_messages'" + +**Step 3: Write minimal implementation** + +Create `backend/src/v1/inbox_messages/__init__.py`: +```python +``` + +Create `backend/src/v1/inbox_messages/schemas.py`: +```python +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class InboxMessageType(str, Enum): + FRIEND_REQUEST = "friend_request" + CALENDAR = "calendar" + SYSTEM = "system" + GROUP = "group" + + +class InboxMessageStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + DISMISSED = "dismissed" + + +class InboxMessageResponse(BaseModel): + id: UUID + recipient_id: UUID + sender_id: Optional[UUID] = None + message_type: InboxMessageType + schedule_item_id: Optional[UUID] = None + content: Optional[str] = None + is_read: bool = False + status: InboxMessageStatus = InboxMessageStatus.PENDING + created_at: datetime + + +class InboxMessageListRequest(BaseModel): + status: Optional[InboxMessageStatus] = None + + +class InboxMessageAcceptRequest(BaseModel): + permission_view: bool = True + permission_edit: bool = False + permission_invite: bool = False +``` + +Create `backend/src/v1/inbox_messages/repository.py`: +```python +from __future__ import annotations + +from typing import Optional +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.inbox_messages import InboxMessage, InboxMessageStatus + + +class InboxMessageRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create(self, data: dict) -> InboxMessage: + msg = InboxMessage(**data) + self._session.add(msg) + await self._session.flush() + return msg + + async def get_by_id(self, message_id: UUID, recipient_id: UUID) -> Optional[InboxMessage]: + result = await self._session.execute( + select(InboxMessage).where( + InboxMessage.id == message_id, + InboxMessage.recipient_id == recipient_id, + ) + ) + return result.scalar_one_or_none() + + async def list_by_recipient( + self, recipient_id: UUID, status: Optional[InboxMessageStatus] = None + ) -> list[InboxMessage]: + query = select(InboxMessage).where(InboxMessage.recipient_id == recipient_id) + if status: + query = query.where(InboxMessage.status == status) + query = query.order_by(InboxMessage.created_at.desc()) + result = await self._session.execute(query) + return list(result.scalars().all()) + + async def update_status( + self, message_id: UUID, recipient_id: UUID, status: InboxMessageStatus + ) -> Optional[InboxMessage]: + msg = await self.get_by_id(message_id, recipient_id) + if msg: + msg.status = status + await self._session.flush() + return msg +``` + +Create `backend/src/v1/inbox_messages/service.py`: +```python +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.inbox_messages import InboxMessageStatus +from v1.inbox_messages.repository import InboxMessageRepository +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageListRequest, + InboxMessageResponse, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.inbox_messages.service") + + +class InboxMessageService(BaseService): + _repository: InboxMessageRepository + _session: AsyncSession + + def __init__( + self, + repository: InboxMessageRepository, + session: AsyncSession, + current_user: Optional[CurrentUser] = None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def list_messages( + self, request: InboxMessageListRequest + ) -> list[InboxMessageResponse]: + user_id = self.require_user_id() + + try: + messages = await self._repository.list_by_recipient( + user_id, request.status + ) + except SQLAlchemyError: + logger.exception("Failed to list inbox messages") + raise HTTPException(status_code=503, detail="Inbox unavailable") + + return [ + InboxMessageResponse( + id=m.id, + recipient_id=m.recipient_id, + sender_id=m.sender_id, + message_type=m.message_type, + schedule_item_id=m.schedule_item_id, + content=m.content, + is_read=m.is_read, + status=m.status, + created_at=m.created_at, + ) + for m in messages + ] + + async def accept_invitation( + self, message_id: UUID, request: InboxMessageAcceptRequest + ) -> None: + user_id = self.require_user_id() + + try: + message = await self._repository.get_by_id(message_id, user_id) + except SQLAlchemyError: + logger.exception("Failed to get inbox message", message_id=str(message_id)) + raise HTTPException(status_code=503, detail="Inbox unavailable") + + if message is None: + raise HTTPException(status_code=404, detail="Message not found") + + if message.message_type != InboxMessageStatus.PENDING: + raise HTTPException(status_code=400, detail="Message already processed") + + message.status = InboxMessageStatus.ACCEPTED + await self._session.flush() + await self._session.commit() + + async def dismiss_invitation(self, message_id: UUID) -> None: + user_id = self.require_user_id() + + try: + message = await self._repository.get_by_id(message_id, user_id) + except SQLAlchemyError: + logger.exception("Failed to get inbox message", message_id=str(message_id)) + raise HTTPException(status_code=503, detail="Inbox unavailable") + + if message is None: + raise HTTPException(status_code=404, detail="Message not found") + + message.status = InboxMessageStatus.DISMISSED + await self._session.flush() + await self._session.commit() +``` + +Create `backend/src/v1/inbox_messages/dependencies.py`: +```python +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.dependencies import get_current_user +from core.db.session import get_db +from models.auth.models import CurrentUser +from v1.inbox_messages.repository import InboxMessageRepository +from v1.inbox_messages.service import InboxMessageService + + +def get_inbox_message_repository( + session: Annotated[AsyncSession, Depends(get_db)] +) -> InboxMessageRepository: + return InboxMessageRepository(session) + + +def get_inbox_message_service( + repository: Annotated[InboxMessageRepository, Depends(get_inbox_message_repository)], + current_user: Annotated[CurrentUser | None, Depends(get_current_user)], +) -> InboxMessageService: + return InboxMessageService( + repository=repository, + session=repository._session, + current_user=current_user, + ) +``` + +Create `backend/src/v1/inbox_messages/router.py`: +```python +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from v1.inbox_messages.dependencies import get_inbox_message_service +from v1.inbox_messages.schemas import ( + InboxMessageAcceptRequest, + InboxMessageListRequest, + InboxMessageResponse, + InboxMessageStatus, +) +from v1.inbox_messages.service import InboxMessageService + + +router = APIRouter(prefix="/inbox", tags=["inbox"]) + + +@router.get("/messages", response_model=list[InboxMessageResponse]) +async def list_inbox_messages( + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], + status: InboxMessageStatus | None = Query(None, description="Filter by status"), +) -> list[InboxMessageResponse]: + request = InboxMessageListRequest(status=status) + return await service.list_messages(request) + + +@router.post("/messages/{message_id}/accept", status_code=204) +async def accept_invitation( + message_id: UUID, + request: InboxMessageAcceptRequest, + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], +) -> None: + await service.accept_invitation(message_id, request) + + +@router.post("/messages/{message_id}/dismiss", status_code=204) +async def dismiss_invitation( + message_id: UUID, + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], +) -> None: + await service.dismiss_invitation(message_id) +``` + +Modify `backend/src/v1/router.py`: +```python +from fastapi import APIRouter + +from core.http.models import HealthResponse +from v1.agent_chat.router import router as agent_chat_router +from v1.auth.router import router as auth_router +from v1.infra.router import router as infra_router +from v1.inbox_messages.router import router as inbox_messages_router +from v1.schedule_items.router import router as schedule_items_router +from v1.users.router import router as users_router + + +router = APIRouter(prefix="/api/v1") +router.include_router(auth_router) +router.include_router(infra_router) +router.include_router(users_router) +router.include_router(agent_chat_router) +router.include_router(schedule_items_router) +router.include_router(inbox_messages_router) + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse(status="ok") +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/v1/inbox_messages/ backend/src/v1/router.py +git commit -m "feat: add inbox messages module for calendar invitations" +``` + +--- + +## Task 2: Add share calendar API to schedule_items + +**Files:** +- Modify: `backend/src/v1/schedule_items/schemas.py` - add share schemas +- Modify: `backend/src/v1/schedule_items/repository.py` - add subscription create +- Modify: `backend/src/v1/schedule_items/service.py` - add share method +- Modify: `backend/src/v1/schedule_items/router.py` - add share endpoint +- Modify: `backend/src/v1/schedule_items/dependencies.py` - add dependencies + +**Step 1: Write the failing test** + +```python +# backend/tests/unit/v1/schedule_items/test_share.py +import pytest +from uuid import uuid4 +from v1.schedule_items.schemas import ScheduleItemShareRequest + +def test_share_request_schema(): + request = ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=True, + permission_invite=False, + ) + assert request.email == "friend@example.com" + assert request.permission_view is True + +def test_permission_bits_calculation(): + request = ScheduleItemShareRequest( + email="friend@example.com", + permission_view=True, + permission_edit=True, + permission_invite=False, + ) + # view=1, edit=4, invite=0 -> 1|4 = 5 + assert request._permission_value() == 5 +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v` +Expected: FAIL with "cannot import 'ScheduleItemShareRequest'" + +**Step 3: Write minimal implementation** + +Add to `backend/src/v1/schedule_items/schemas.py`: +```python +class ScheduleItemShareRequest(BaseModel): + email: str = Field(..., description="Email of user to share with") + permission_view: bool = Field(True, description="Grant view permission") + permission_edit: bool = Field(False, description="Grant edit permission") + permission_invite: bool = Field(False, description="Grant invite permission") + + def _permission_value(self) -> int: + value = 0 + if self.permission_view: + value |= 1 # 001 + if self.permission_edit: + value |= 4 # 100 + if self.permission_invite: + value |= 2 # 010 + return value + + +class ScheduleItemShareResponse(BaseModel): + message: str +``` + +Add to `backend/src/v1/schedule_items/repository.py`: +```python +from models.schedule_subscriptions import ScheduleSubscription + + +class ScheduleItemRepository: + # ... existing code ... + + async def create_subscription(self, data: dict) -> ScheduleSubscription: + sub = ScheduleSubscription(**data) + self._session.add(sub) + await self._session.flush() + return sub +``` + +Add to `backend/src/v1/schedule_items/service.py`: +```python +from uuid import UUID + +from core.auth.models import CurrentUser +from v1.auth.gateway import SupabaseAuthGateway +from models.schedule_subscriptions import ScheduleSubscription + + +class ScheduleItemService: + # ... existing code ... + + async def share( + self, item_id: UUID, request: ScheduleItemShareRequest + ) -> ScheduleItemShareResponse: + user_id = self.require_user_id() + + # Check item exists and user is owner + try: + item = await self._repository.get_by_item_id(item_id, user_id) + except SQLAlchemyError: + logger.exception("Failed to get schedule item", item_id=str(item_id)) + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + if item is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + + if item.owner_id != user_id: + raise HTTPException(status_code=403, detail="Only owner can share") + + # Lookup user by email + auth_gateway = SupabaseAuthGateway() + try: + target_user = await auth_gateway.get_user_by_email(request.email) + except HTTPException as exc: + if exc.status_code == 404: + raise HTTPException(status_code=404, detail="User not found") + raise + + target_user_id = UUID(target_user.id) + + # Create inbox message + from models.inbox_messages import InboxMessage, InboxMessageType + inbox_data = { + "recipient_id": target_user_id, + "sender_id": user_id, + "message_type": InboxMessageType.CALENDAR, + "schedule_item_id": item_id, + "content": f"{item.title} shared with you", + "created_by": user_id, + } + try: + inbox_msg = InboxMessage(**inbox_data) + self._session.add(inbox_msg) + await self._session.flush() + except SQLAlchemyError: + logger.exception("Failed to create inbox message") + raise HTTPException(status_code=503, detail="Failed to send invitation") + + await self._session.commit() + return ScheduleItemShareResponse( + message=f"Invitation sent to {request.email}" + ) +``` + +Add to `backend/src/v1/schedule_items/router.py`: +```python +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemListItem, + ScheduleItemListRequest, + ScheduleItemResponse, + ScheduleItemShareRequest, + ScheduleItemShareResponse, + ScheduleItemUpdateRequest, +) + + +@router.post("/{item_id}/share", response_model=ScheduleItemShareResponse) +async def share_schedule_item( + item_id: UUID, + request: ScheduleItemShareRequest, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> ScheduleItemShareResponse: + return await service.share(item_id, request) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/v1/schedule_items/ +git commit -m "feat: add share calendar API" +``` + +--- + +## Task 3: Add accept invitation - create subscription + +**Files:** +- Modify: `backend/src/v1/inbox_messages/service.py` - add subscription creation on accept + +**Step 1: Write the failing test** + +```python +# backend/tests/unit/v1/inbox_messages/test_accept_invitation.py +import pytest +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 +from v1.inbox_messages.service import InboxMessageService + +@pytest.mark.asyncio +async def test_accept_creates_subscription(): + # Setup mocks + mock_repo = MagicMock() + mock_session = MagicMock() + mock_message = MagicMock() + mock_message.id = uuid4() + mock_message.message_type = "calendar" + mock_message.status = "pending" + mock_message.schedule_item_id = uuid4() + mock_repo.get_by_id = AsyncMock(return_value=mock_message) + mock_repo._session = mock_session + + service = InboxMessageService( + repository=mock_repo, + session=mock_session, + current_user=MagicMock(user_id=uuid4()), + ) + + # This should be implemented + await service.accept_invitation(mock_message.id, ...) +``` + +**Step 2: Run test to verify it fails** + +Expected: FAIL (test will fail because accept doesn't create subscription yet) + +**Step 3: Write implementation** + +Modify `backend/src/v1/inbox_messages/service.py` to import ScheduleSubscriptionRepository and create subscription on accept. + +**Step 4: Run test to verify it passes** + +Run tests and verify pass. + +**Step 5: Commit** + +--- + +## Task 4: Fix permission enum reference bug + +**Files:** +- Modify: `backend/src/v1/inbox_messages/service.py` - fix InboxMessageStatus reference + +**Bug:** In Task 1, we used `InboxMessageStatus.PENDING` but should check against the actual enum type. Fix the bug. + +**Step 1: Write test to verify bug** + +```python +def test_accept_checks_message_type_not_status(): + # Current code incorrectly checks message_type == PENDING + # Should check status == PENDING +``` + +**Step 2: Fix the implementation** + +--- + +## Task 5: Write unit tests + +**Files:** +- Create: `backend/tests/unit/v1/inbox_messages/test_service.py` +- Create: `backend/tests/unit/v1/schedule_items/test_share.py` + +--- + +## Task 6: Write integration tests + +**Files:** +- Create: `backend/tests/integration/test_inbox_messages_routes.py` +- Create: `backend/tests/integration/test_schedule_share_routes.py` + +--- + +## Task 7: Update API documentation + +**Files:** +- Modify: `docs/runtime/runtime-route.md` - add share/inbox endpoints + +--- + +## Task 8: Run all tests and fix issues + +Run full test suite and fix any issues. + +--- + +## Task 9: Run lint and typecheck + +Run: +```bash +cd backend && uv run ruff check src/v1/schedule_items/ src/v1/inbox_messages/ +cd backend && uv run basedpyright src/v1/schedule_items/ src/v1/inbox_messages/ +``` + +--- + +## Task 10: Final commit and create PR + +```bash +git add . +git commit -m "feat: add calendar sharing with invitations" +git push -u origin feature-calendar-sharing +gh pr create --title "feat: add calendar sharing" --body "..." +``` diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md index 89b67c2..bf5dbda 100644 --- a/docs/runtime/runtime-route.md +++ b/docs/runtime/runtime-route.md @@ -358,6 +358,115 @@ --- +### POST /schedule-items/{id}/share + +分享日历事项给他人(需要认证)。 + +通过邮箱邀请其他用户,被邀请人将收到待办消息邀请。 + +**Request:** +```json +{ + "email": "string (required, email of user to share with)", + "permission_view": "boolean (default: true)", + "permission_edit": "boolean (default: false)", + "permission_invite": "boolean (default: false)" +} +``` + +**Permission 位说明:** +| 权限 | 值 | 说明 | +|------|-----|------| +| view | 1 | 查看事项详情 | +| invite | 2 | 邀请其他人订阅此事项 | +| edit | 4 | 修改事项内容、管理订阅 | + +可组合使用,如 view+edit = 5,view+invite+edit = 7。 + +**Response:** 200 OK +```json +{ + "message": "Invitation sent to user@example.com" +} +``` + +**Errors:** +- 401: 未认证 +- 403: 非日历所有者无权分享 +- 404: 日历事项不存在或用户不存在 + +--- + +## Inbox Messages + +## Inbox Messages + +### GET /inbox/messages + +获取当前用户的待办消息列表(需要认证)。 + +**Query Parameters:** +- `status`: string (optional) - 过滤状态:`pending`/`accepted`/`rejected`/`dismissed` + +**Response:** 200 OK +```json +[ + { + "id": "uuid", + "recipient_id": "uuid", + "sender_id": "uuid?", + "message_type": "calendar", + "schedule_item_id": "uuid?", + "content": "string?", + "is_read": false, + "status": "pending", + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +**Errors:** +- 401: 未认证 + +--- + +### POST /inbox/messages/{id}/accept + +接受邀请(需要认证)。 + +接受日历邀请时,会为当前用户创建订阅关系。 + +**Request:** +```json +{ + "permission_view": "boolean (default: true)", + "permission_edit": "boolean (default: false)", + "permission_invite": "boolean (default: false)" +} +``` + +**Response:** 204 No Content + +**Errors:** +- 401: 未认证 +- 404: 消息不存在 +- 400: 消息不是待处理状态或不是日历类型邀请 + +--- + +### POST /inbox/messages/{id}/dismiss + +忽略邀请(需要认证)。 + +**Response:** 204 No Content + +**Errors:** +- 401: 未认证 +- 404: 消息不存在 +- 400: 消息不是待处理状态 + +--- + ## Users ### GET /users/me From ce8cd1d31f3f217becf466587840bef05bcb13c9 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:40:40 +0800 Subject: [PATCH 4/6] fix: address CRITICAL security issues - permission escalation and encoding inconsistency --- backend/src/v1/inbox_messages/schemas.py | 27 ++++++++++++ backend/src/v1/inbox_messages/service.py | 43 +++++++++++++------ backend/src/v1/schedule_items/schemas.py | 6 ++- .../unit/v1/inbox_messages/test_service.py | 5 ++- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/backend/src/v1/inbox_messages/schemas.py b/backend/src/v1/inbox_messages/schemas.py index d5b028b..a498703 100644 --- a/backend/src/v1/inbox_messages/schemas.py +++ b/backend/src/v1/inbox_messages/schemas.py @@ -8,6 +8,31 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict +class PermissionBits: + VIEW = 1 # 001 + INVITE = 2 # 010 + EDIT = 4 # 100 + + @classmethod + def encode(cls, view: bool, edit: bool, invite: bool) -> int: + value = 0 + if view: + value |= cls.VIEW + if edit: + value |= cls.EDIT + if invite: + value |= cls.INVITE + return value + + @classmethod + def decode(cls, permission: int) -> dict[str, bool]: + return { + "view": bool(permission & cls.VIEW), + "edit": bool(permission & cls.EDIT), + "invite": bool(permission & cls.INVITE), + } + + class InboxMessageType(str, Enum): FRIEND_REQUEST = "friend_request" CALENDAR = "calendar" @@ -41,6 +66,8 @@ class InboxMessageListRequest(BaseModel): class InboxMessageAcceptRequest(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + permission_view: bool = True permission_edit: bool = False permission_invite: bool = False diff --git a/backend/src/v1/inbox_messages/service.py b/backend/src/v1/inbox_messages/service.py index 52061b0..467a67b 100644 --- a/backend/src/v1/inbox_messages/service.py +++ b/backend/src/v1/inbox_messages/service.py @@ -4,6 +4,8 @@ from enum import Enum from typing import TYPE_CHECKING from uuid import UUID +import json + from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError @@ -22,6 +24,7 @@ from v1.inbox_messages.schemas import ( InboxMessageResponse, InboxMessageStatus, InboxMessageType, + PermissionBits, ) if TYPE_CHECKING: @@ -84,11 +87,23 @@ class InboxMessageService(BaseService): status_code=400, detail="Message is not a calendar invitation" ) - permission = self._encode_permission(request) + invited_permission = self._parse_invited_permission(message.content) + requested_permission = PermissionBits.encode( + request.permission_view, + request.permission_edit, + request.permission_invite, + ) + final_permission = requested_permission & invited_permission + if final_permission == 0: + raise HTTPException( + status_code=400, + detail="No valid permissions requested (must be subset of invited permissions)", + ) + subscription = ScheduleSubscription( item_id=message.schedule_item_id, subscriber_id=user_id, - permission=permission, + permission=final_permission, status=SubscriptionStatus.ACTIVE, created_by=user_id, ) @@ -98,7 +113,12 @@ class InboxMessageService(BaseService): user_id, InboxMessageStatus.ACCEPTED.value, ) + if updated is None: + await self._session.rollback() + raise HTTPException(status_code=404, detail="Inbox message not found") await self._session.commit() + except HTTPException: + raise except SQLAlchemyError: await self._session.rollback() logger.exception( @@ -110,8 +130,6 @@ class InboxMessageService(BaseService): status_code=503, detail="Inbox message store unavailable" ) - if updated is None: - raise HTTPException(status_code=404, detail="Inbox message not found") return self._to_response(updated) async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse: @@ -160,15 +178,14 @@ class InboxMessageService(BaseService): created_at=message.created_at, ) - def _encode_permission(self, request: InboxMessageAcceptRequest) -> int: - permission = 0 - if request.permission_view: - permission |= 1 - if request.permission_edit: - permission |= 2 - if request.permission_invite: - permission |= 4 - return permission + def _parse_invited_permission(self, content: str | None) -> int: + if not content: + return 0 + try: + data = json.loads(content) + return int(data.get("permission", 0)) + except (json.JSONDecodeError, ValueError, TypeError): + return 0 def _status_value(self, status: object) -> str: if isinstance(status, Enum): diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index 150194a..ef05511 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -5,7 +5,7 @@ from enum import Enum from typing import ClassVar from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field class AttachmentType(str, Enum): @@ -99,7 +99,9 @@ class ScheduleItemListRequest(BaseModel): class ScheduleItemShareRequest(BaseModel): - email: str = Field(..., description="Email of user to share with") + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + email: EmailStr = Field(..., description="Email of user to share with") permission_view: bool = Field(True, description="Grant view permission") permission_edit: bool = Field(False, description="Grant edit permission") permission_invite: bool = Field(False, description="Grant invite permission") diff --git a/backend/tests/unit/v1/inbox_messages/test_service.py b/backend/tests/unit/v1/inbox_messages/test_service.py index a2e136d..38dd403 100644 --- a/backend/tests/unit/v1/inbox_messages/test_service.py +++ b/backend/tests/unit/v1/inbox_messages/test_service.py @@ -23,6 +23,7 @@ def _build_message( status: InboxMessageModelStatus = InboxMessageModelStatus.PENDING, message_type: InboxMessageModelType = InboxMessageModelType.CALENDAR, schedule_item_id: UUID | None = None, + content: str = '{"permission": 7}', ) -> InboxMessage: message = MagicMock(spec=InboxMessage) message.id = message_id @@ -30,7 +31,7 @@ def _build_message( message.sender_id = uuid4() message.message_type = message_type message.schedule_item_id = schedule_item_id - message.content = "calendar invite" + message.content = content message.is_read = False message.status = status message.created_at = datetime(2026, 2, 28, 10, 0, 0, tzinfo=timezone.utc) @@ -107,7 +108,7 @@ async def test_accept_invitation_creates_subscription() -> None: assert isinstance(subscription, ScheduleSubscription) assert subscription.item_id == item_id assert subscription.subscriber_id == user_id - assert subscription.permission == 3 + assert subscription.permission == 5 # view(1) + edit(4) = 5 assert subscription.status == SubscriptionStatus.ACTIVE repo.update_status.assert_awaited_once_with(message_id, user_id, "accepted") session.commit.assert_awaited_once() From c22692ed1d24b910e2bf46f024df9e63fd148594 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:48:01 +0800 Subject: [PATCH 5/6] refactor: simplify code - extract constants, reduce complexity --- backend/src/v1/inbox_messages/dependencies.py | 2 +- backend/src/v1/inbox_messages/service.py | 22 ++++----------- backend/src/v1/schedule_items/router.py | 12 +------- backend/src/v1/schedule_items/schemas.py | 12 ++++++-- backend/src/v1/schedule_items/service.py | 28 ++++++------------- 5 files changed, 25 insertions(+), 51 deletions(-) diff --git a/backend/src/v1/inbox_messages/dependencies.py b/backend/src/v1/inbox_messages/dependencies.py index d65ebb1..5a1cb30 100644 --- a/backend/src/v1/inbox_messages/dependencies.py +++ b/backend/src/v1/inbox_messages/dependencies.py @@ -6,7 +6,7 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from core.auth.models import CurrentUser -from core.db.session import get_db +from core.db import get_db from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository from v1.inbox_messages.service import InboxMessageService from v1.users.dependencies import get_current_user diff --git a/backend/src/v1/inbox_messages/service.py b/backend/src/v1/inbox_messages/service.py index 467a67b..4a299e2 100644 --- a/backend/src/v1/inbox_messages/service.py +++ b/backend/src/v1/inbox_messages/service.py @@ -1,6 +1,5 @@ from __future__ import annotations -from enum import Enum from typing import TYPE_CHECKING from uuid import UUID @@ -74,13 +73,12 @@ class InboxMessageService(BaseService): message = await self._repository.get_by_id(message_id, user_id) if message is None: raise HTTPException(status_code=404, detail="Inbox message not found") - if self._status_value(message.status) != InboxMessageStatus.PENDING.value: + if message.status.value != InboxMessageStatus.PENDING.value: raise HTTPException( status_code=400, detail="Inbox message already handled" ) if ( - self._type_value(message.message_type) - != InboxMessageType.CALENDAR.value + message.message_type.value != InboxMessageType.CALENDAR.value or message.schedule_item_id is None ): raise HTTPException( @@ -139,7 +137,7 @@ class InboxMessageService(BaseService): message = await self._repository.get_by_id(message_id, user_id) if message is None: raise HTTPException(status_code=404, detail="Inbox message not found") - if self._status_value(message.status) != InboxMessageStatus.PENDING.value: + if message.status.value != InboxMessageStatus.PENDING.value: raise HTTPException( status_code=400, detail="Inbox message already handled" ) @@ -170,11 +168,11 @@ class InboxMessageService(BaseService): id=message.id, recipient_id=message.recipient_id, sender_id=message.sender_id, - message_type=InboxMessageType(self._type_value(message.message_type)), + message_type=InboxMessageType(message.message_type), schedule_item_id=message.schedule_item_id, content=message.content, is_read=bool(message.is_read), - status=InboxMessageStatus(self._status_value(message.status)), + status=InboxMessageStatus(message.status), created_at=message.created_at, ) @@ -186,13 +184,3 @@ class InboxMessageService(BaseService): return int(data.get("permission", 0)) except (json.JSONDecodeError, ValueError, TypeError): return 0 - - def _status_value(self, status: object) -> str: - if isinstance(status, Enum): - return str(status.value) - return str(status) - - def _type_value(self, message_type: object) -> str: - if isinstance(message_type, Enum): - return str(message_type.value) - return str(message_type) diff --git a/backend/src/v1/schedule_items/router.py b/backend/src/v1/schedule_items/router.py index 0ff63da..e2d9922 100644 --- a/backend/src/v1/schedule_items/router.py +++ b/backend/src/v1/schedule_items/router.py @@ -38,17 +38,7 @@ async def list_schedule_items( ) -> list[ScheduleItemListItem]: request = ScheduleItemListRequest(start_at=start_at, end_at=end_at) items = await service.list_by_date_range(request) - return [ - ScheduleItemListItem( - id=item.id, - title=item.title, - start_at=item.start_at, - end_at=item.end_at, - timezone=item.timezone, - status=item.status, - ) - for item in items - ] + return [ScheduleItemListItem.model_validate(item) for item in items] @router.get("/{item_id}", response_model=ScheduleItemResponse) diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index ef05511..ac4a849 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -98,6 +98,12 @@ class ScheduleItemListRequest(BaseModel): end_at: datetime +# Permission bit constants (matching PermissionBits in inbox_messages/schemas.py) +_PERMISSION_VIEW = 1 # 001 +_PERMISSION_INVITE = 2 # 010 +_PERMISSION_EDIT = 4 # 100 + + class ScheduleItemShareRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -109,11 +115,11 @@ class ScheduleItemShareRequest(BaseModel): def _permission_value(self) -> int: value = 0 if self.permission_view: - value |= 1 + value |= _PERMISSION_VIEW if self.permission_edit: - value |= 4 + value |= _PERMISSION_EDIT if self.permission_invite: - value |= 2 + value |= _PERMISSION_INVITE return value diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 89b0596..73642e5 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -112,26 +112,16 @@ class ScheduleItemService(BaseService): if existing is None: raise HTTPException(status_code=404, detail="Schedule item not found") - update_data: dict = {} - if request.title is not None: - update_data["title"] = request.title - if request.description is not None: - update_data["description"] = request.description - if request.start_at is not None: - update_data["start_at"] = request.start_at - if request.end_at is not None: - update_data["end_at"] = request.end_at - if request.timezone is not None: - update_data["timezone"] = request.timezone - if request.status is not None: - update_data["status"] = request.status - if request.metadata is not None: - update_data["metadata"] = request.metadata.model_dump() + # Build update dict from non-null fields + update_data = request.model_dump(exclude_unset=True) - next_start = ( - request.start_at if request.start_at is not None else existing.start_at - ) - next_end = request.end_at if request.end_at is not None else existing.end_at + # Handle metadata separately (model_dump returns dict) + if "metadata" in update_data and update_data["metadata"] is not None: + update_data["metadata"] = update_data["metadata"].model_dump() + + # Validate time range + next_start = update_data.get("start_at", existing.start_at) + next_end = update_data.get("end_at", existing.end_at) if next_end is not None and next_end <= next_start: raise HTTPException( status_code=400, detail="end_at must be after start_at" From 62500160ae7544f89018eaa3e8e90018daa60c66 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:26:20 +0800 Subject: [PATCH 6/6] fix: add type annotations to PermissionBits --- backend/src/v1/inbox_messages/schemas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/v1/inbox_messages/schemas.py b/backend/src/v1/inbox_messages/schemas.py index a498703..8cd6935 100644 --- a/backend/src/v1/inbox_messages/schemas.py +++ b/backend/src/v1/inbox_messages/schemas.py @@ -9,9 +9,9 @@ from pydantic import BaseModel, ConfigDict class PermissionBits: - VIEW = 1 # 001 - INVITE = 2 # 010 - EDIT = 4 # 100 + VIEW: int = 1 # 001 + INVITE: int = 2 # 010 + EDIT: int = 4 # 100 @classmethod def encode(cls, view: bool, edit: bool, invite: bool) -> int: