Merge branch 'feature-calendar-sharing' into dev
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 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,73 @@
|
||||
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 PermissionBits:
|
||||
VIEW: int = 1 # 001
|
||||
INVITE: int = 2 # 010
|
||||
EDIT: int = 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"
|
||||
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):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
permission_view: bool = True
|
||||
permission_edit: bool = False
|
||||
permission_invite: bool = False
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
import json
|
||||
|
||||
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,
|
||||
PermissionBits,
|
||||
)
|
||||
|
||||
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 message.status.value != InboxMessageStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Inbox message already handled"
|
||||
)
|
||||
if (
|
||||
message.message_type.value != InboxMessageType.CALENDAR.value
|
||||
or message.schedule_item_id is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Message is not a calendar invitation"
|
||||
)
|
||||
|
||||
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=final_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,
|
||||
)
|
||||
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(
|
||||
"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"
|
||||
)
|
||||
|
||||
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 message.status.value != 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(message.message_type),
|
||||
schedule_item_id=message.schedule_item_id,
|
||||
content=message.content,
|
||||
is_read=bool(message.is_read),
|
||||
status=InboxMessageStatus(message.status),
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -6,6 +6,7 @@ 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.friendships.router import router as friendships_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
|
||||
@@ -18,6 +19,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,8 @@ from v1.schedule_items.schemas import (
|
||||
ScheduleItemListItem,
|
||||
ScheduleItemListRequest,
|
||||
ScheduleItemResponse,
|
||||
ScheduleItemShareRequest,
|
||||
ScheduleItemShareResponse,
|
||||
ScheduleItemUpdateRequest,
|
||||
)
|
||||
from v1.schedule_items.service import ScheduleItemService
|
||||
@@ -36,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)
|
||||
@@ -72,3 +64,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)
|
||||
|
||||
@@ -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):
|
||||
@@ -96,3 +96,32 @@ class ScheduleItemListItem(BaseModel):
|
||||
class ScheduleItemListRequest(BaseModel):
|
||||
start_at: datetime
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
def _permission_value(self) -> int:
|
||||
value = 0
|
||||
if self.permission_view:
|
||||
value |= _PERMISSION_VIEW
|
||||
if self.permission_edit:
|
||||
value |= _PERMISSION_EDIT
|
||||
if self.permission_invite:
|
||||
value |= _PERMISSION_INVITE
|
||||
return value
|
||||
|
||||
|
||||
class ScheduleItemShareResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
@@ -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()
|
||||
@@ -98,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"
|
||||
@@ -179,6 +183,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,
|
||||
|
||||
Reference in New Issue
Block a user