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