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

1"""Inbox service — high-level user-facing inbox operations.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

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 

11 

12if TYPE_CHECKING: 

13 from lexigram.contracts.notification.inbox import InboxStoreProtocol 

14 

15logger = get_logger(__name__) 

16 

17 

18class InboxService: 

19 """High-level service for managing per-user inbox notifications. 

20 

21 Wraps an :class:`InboxStoreProtocol` backend and exposes a clean API 

22 consumed by controllers, event handlers, or a notification bell widget. 

23 

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. 

28 

29 Args: 

30 store: Persistence backend. Defaults to ``InMemoryInboxStore()``. 

31 """ 

32 

33 def __init__( 

34 self, 

35 store: InboxStoreProtocol | None = None, 

36 ) -> None: 

37 self._store: InboxStoreProtocol = store or InMemoryInboxStore() 

38 

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*. 

47 

48 Keyword arguments beyond *body* are collected into the message 

49 ``metadata`` dict so callers can attach arbitrary context:: 

50 

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 ) 

58 

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``. 

64 

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 

89 

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*. 

97 

98 Args: 

99 user_id: User whose inbox to fetch. 

100 unread_only: When ``True`` only unread messages are returned. 

101 

102 Returns: 

103 Messages in reverse-chronological order. 

104 """ 

105 return await self._store.list_for_user(user_id, unread_only=unread_only) 

106 

107 async def get_message(self, message_id: str) -> InboxMessage | None: 

108 """Return a single inbox message by ID. 

109 

110 Args: 

111 message_id: Message to retrieve. 

112 

113 Returns: 

114 The message, or ``None`` if not found. 

115 """ 

116 return await self._store.get(message_id) 

117 

118 async def mark_read(self, message_id: str, user_id: str) -> None: 

119 """Mark *message_id* as read. 

120 

121 The *user_id* guard ensures a user can only mark their own messages. 

122 

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) 

128 

129 async def mark_all_read(self, user_id: str) -> None: 

130 """Mark all of *user_id*'s messages as read. 

131 

132 Args: 

133 user_id: Target user. 

134 """ 

135 await self._store.mark_all_read(user_id) 

136 

137 async def delete(self, message_id: str, user_id: str) -> None: 

138 """Delete a message owned by *user_id*. 

139 

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) 

145 

146 async def count_unread(self, user_id: str) -> int: 

147 """Return the unread message count for *user_id*. 

148 

149 Args: 

150 user_id: Target user. 

151 

152 Returns: 

153 Number of unread messages. 

154 """ 

155 return await self._store.count_unread(user_id) 

156 

157 async def clear_all(self, user_id: str) -> int: 

158 """Delete all messages for *user_id*. 

159 

160 Args: 

161 user_id: Target user. 

162 

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 

169 

170 

171__all__ = ["InboxService"]