Coverage for src/lexigram/notification/inbox/service.py: 91%
33 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 02:32 +0800
1"""Inbox service — high-level user-facing inbox operations."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.contracts.notification.inbox import INBOX_SENT_HOOK, InboxMessage
8from lexigram.hooks.ambient import fire as fire_hook
9from lexigram.logging import get_logger
10from lexigram.notification.inbox.memory import InMemoryInboxStore
12if TYPE_CHECKING:
13 from lexigram.contracts.notification.inbox import InboxStoreProtocol
15logger = get_logger(__name__)
18class InboxService:
19 """High-level service for managing per-user inbox notifications.
21 Wraps an :class:`InboxStoreProtocol` backend and exposes a clean API
22 consumed by controllers, event handlers, or a notification bell widget.
24 Defaults to :class:`InMemoryInboxStore` when no *store* is supplied so
25 that the service can be used without DI wiring in simple scenarios (e.g.
26 tests, CLI tools). Production deployments should inject a
27 :class:`DatabaseInboxStore` via the DI container.
29 Args:
30 store: Persistence backend. Defaults to ``InMemoryInboxStore()``.
31 """
33 def __init__(
34 self,
35 store: InboxStoreProtocol | None = None,
36 ) -> None:
37 self._store: InboxStoreProtocol = store or InMemoryInboxStore()
39 async def send(
40 self,
41 user_id: str,
42 title: str,
43 body: str,
44 **metadata: Any,
45 ) -> InboxMessage:
46 """Create and persist an inbox notification for *user_id*.
48 Keyword arguments beyond *body* are collected into the message
49 ``metadata`` dict so callers can attach arbitrary context::
51 await inbox.send(
52 user_id="u1",
53 title="Order shipped",
54 body="Your order #123 is on the way.",
55 order_id="123",
56 tracking_url="https://example.com/track/123",
57 )
59 Args:
60 user_id: Recipient user ID.
61 title: Short notification title shown in list views.
62 body: Full message body.
63 **metadata: Arbitrary key-value pairs stored in ``metadata``.
65 Returns:
66 The newly created and persisted :class:`InboxMessage`.
67 """
68 message = InboxMessage.create(
69 user_id=user_id,
70 title=title,
71 body=body,
72 metadata=dict(metadata) if metadata else None,
73 )
74 await self._store.save(message)
75 logger.info(
76 "inbox.sent",
77 message_id=message.id,
78 user_id=user_id,
79 title=title,
80 )
81 await fire_hook(
82 INBOX_SENT_HOOK,
83 message=message,
84 user_id=user_id,
85 title=title,
86 body=body,
87 )
88 return message
90 async def get_inbox(
91 self,
92 user_id: str,
93 *,
94 unread_only: bool = False,
95 ) -> list[InboxMessage]:
96 """Return inbox messages for *user_id*.
98 Args:
99 user_id: User whose inbox to fetch.
100 unread_only: When ``True`` only unread messages are returned.
102 Returns:
103 Messages in reverse-chronological order.
104 """
105 return await self._store.list_for_user(user_id, unread_only=unread_only)
107 async def get_message(self, message_id: str) -> InboxMessage | None:
108 """Return a single inbox message by ID.
110 Args:
111 message_id: Message to retrieve.
113 Returns:
114 The message, or ``None`` if not found.
115 """
116 return await self._store.get(message_id)
118 async def mark_read(self, message_id: str, user_id: str) -> None:
119 """Mark *message_id* as read.
121 The *user_id* guard ensures a user can only mark their own messages.
123 Args:
124 message_id: ID of the message to mark.
125 user_id: Owning user (authorisation guard).
126 """
127 await self._store.mark_read(message_id, user_id)
129 async def mark_all_read(self, user_id: str) -> None:
130 """Mark all of *user_id*'s messages as read.
132 Args:
133 user_id: Target user.
134 """
135 await self._store.mark_all_read(user_id)
137 async def delete(self, message_id: str, user_id: str) -> None:
138 """Delete a message owned by *user_id*.
140 Args:
141 message_id: ID of the message to remove.
142 user_id: Owning user (authorisation guard).
143 """
144 await self._store.delete(message_id, user_id)
146 async def count_unread(self, user_id: str) -> int:
147 """Return the unread message count for *user_id*.
149 Args:
150 user_id: Target user.
152 Returns:
153 Number of unread messages.
154 """
155 return await self._store.count_unread(user_id)
157 async def clear_all(self, user_id: str) -> int:
158 """Delete all messages for *user_id*.
160 Args:
161 user_id: Target user.
163 Returns:
164 Number of messages deleted.
165 """
166 count = await self._store.clear_all(user_id)
167 logger.info("inbox.cleared_all", user_id=user_id, count=count)
168 return count
171__all__ = ["InboxService"]