From 709ae5ab73897fc9985b000f96bcef6c5d2aa13b Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:09:34 +0800 Subject: [PATCH] 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