""" Scribe Controller The Scribe's messaging verbs (dossier core-api T-5): register, list (addresses), send, mailbox, history, find, mark, tag/untag, archive/trash a mailbox entry and look one up by message number (core-api T-13/T-9). Every route is behind an Office or user caller (T-1/T-4); every query filters on the caller's tenant (D-73) — an address or message in another tenant answers exactly like one that does not exist. """ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from src.domains.auth.callers import Caller, require_caller from src.domains.scribe.schemas import ( AddressListResponse, AddressRegisterRequest, AddressResponse, HistoryResponse, MailboxEntryResponse, MailboxListResponse, MailboxPatchRequest, MailboxView, MessageResponse, MessageSendRequest, ) from src.domains.scribe.service import ScribeError, get_scribe_service from src.shared.base import BaseController from src.shared.database import get_async_session from src.shared.logging import get_logger from sqlalchemy.ext.asyncio import AsyncSession logger = get_logger(__name__) require_scribe_reader = require_caller("office", "user") require_scribe_writer = require_caller("office", "user", identity_required=True) def _address_response(address, owned: bool) -> AddressResponse: return AddressResponse( name=address.name, owner_consumer=address.owner_consumer, created_by=address.created_by, created_at=address.created_at, owned=owned, ) def _message_response(message) -> MessageResponse: return MessageResponse.model_validate(message, from_attributes=True) def _entry_response(entry, message) -> MailboxEntryResponse: return MailboxEntryResponse( id=entry.id, address=entry.address, read_at=entry.read_at, archived_at=entry.archived_at, trashed_at=entry.trashed_at, tags=entry.tags, created_at=entry.created_at, message=_message_response(message), ) class ScribeController(BaseController): """Controller for the Scribe domain.""" def __init__(self): super().__init__(prefix="/scribe", tags=["Scribe"]) def create_router(self) -> APIRouter: router = APIRouter(prefix=self.prefix, tags=self.tags) service = get_scribe_service() # ===================================================================== # register / list addresses # ===================================================================== @router.post( "/addresses", response_model=AddressResponse, summary="Register an address (idempotent for the same consumer)", ) async def register_address( data: AddressRegisterRequest, caller: Caller = Depends(require_scribe_writer), session: AsyncSession = Depends(get_async_session), ): try: address, owned = await service.register_address(session, data.name, caller) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return _address_response(address, owned) @router.get( "/addresses", response_model=AddressListResponse, summary="List addresses in the caller's tenant", ) async def list_addresses( caller: Caller = Depends(require_scribe_reader), session: AsyncSession = Depends(get_async_session), ): try: results = await service.list_addresses(session, caller) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return AddressListResponse( addresses=[_address_response(address, owned) for address, owned in results] ) # ===================================================================== # send # ===================================================================== @router.post( "/messages", response_model=MessageResponse, status_code=201, summary="Send a message to one or more addresses", ) async def send_message( data: MessageSendRequest, caller: Caller = Depends(require_scribe_writer), session: AsyncSession = Depends(get_async_session), ): try: message = await service.send_message(session, data, caller) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return _message_response(message) # ===================================================================== # find # ===================================================================== @router.get( "/messages", response_model=MailboxListResponse, summary="Full text search over messages in the caller identity's mailbox", ) async def find_messages( q: str = Query(..., min_length=1), limit: int = Query(50, ge=1, le=200), caller: Caller = Depends(require_scribe_reader), session: AsyncSession = Depends(get_async_session), ): try: results, total = await service.find_messages(session, caller, q, limit) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return MailboxListResponse( entries=[_entry_response(entry, message) for entry, message in results], total=total, limit=limit, ) # ===================================================================== # mailbox # ===================================================================== @router.get( "/mailbox", response_model=MailboxListResponse, summary="The caller identity's mailbox, unread first", ) async def get_mailbox( view: MailboxView = Query( "inbox", description="inbox (default, neither archived nor trashed), archive, trash, all" ), unread: bool = Query(False), tag: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), before_id: Optional[int] = Query(None), message_id: Optional[int] = Query( None, description="look up the caller's own entry for this message number directly, " "in any view, ignoring unread/tag/before_id (core-api T-9)", ), caller: Caller = Depends(require_scribe_reader), session: AsyncSession = Depends(get_async_session), ): try: results, total = await service.get_mailbox( session, caller, view=view, unread=unread, tag=tag, limit=limit, before_id=before_id, message_id=message_id, ) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return MailboxListResponse( entries=[_entry_response(entry, message) for entry, message in results], total=total, limit=limit, ) @router.patch( "/mailbox/{entry_id}", response_model=MailboxEntryResponse, summary="Mark read/unread, or tag/untag a mailbox entry", ) async def patch_mailbox_entry( entry_id: int, data: MailboxPatchRequest, caller: Caller = Depends(require_scribe_writer), session: AsyncSession = Depends(get_async_session), ): try: entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return _entry_response(entry, message) # ===================================================================== # history # ===================================================================== @router.get( "/addresses/{name}/history", response_model=HistoryResponse, summary="The retained messages sent to an address, oldest first", ) async def address_history( name: str, since_id: Optional[int] = Query(None), limit: int = Query(100, ge=1, le=500), caller: Caller = Depends(require_scribe_reader), session: AsyncSession = Depends(get_async_session), ): try: messages = await service.get_history(session, name, caller, since_id, limit) except ScribeError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code, **error.extra} ) from error return HistoryResponse( address=name, messages=[_message_response(message) for message in messages], ) return router # Create controller instance scribe_controller = ScribeController()