feat: add inbox messages module for calendar invitations
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
|||||||
from core.http.models import HealthResponse
|
from core.http.models import HealthResponse
|
||||||
from v1.agent_chat.router import router as agent_chat_router
|
from v1.agent_chat.router import router as agent_chat_router
|
||||||
from v1.auth.router import router as auth_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.infra.router import router as infra_router
|
||||||
from v1.schedule_items.router import router as schedule_items_router
|
from v1.schedule_items.router import router as schedule_items_router
|
||||||
from v1.users.router import router as users_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(users_router)
|
||||||
router.include_router(agent_chat_router)
|
router.include_router(agent_chat_router)
|
||||||
router.include_router(schedule_items_router)
|
router.include_router(schedule_items_router)
|
||||||
|
router.include_router(inbox_messages_router)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user