geek-cafe-saas-sdk 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

Files changed (194) hide show
  1. geek_cafe_saas_sdk/__init__.py +9 -0
  2. geek_cafe_saas_sdk/core/__init__.py +11 -0
  3. geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
  4. geek_cafe_saas_sdk/core/error_codes.py +132 -0
  5. geek_cafe_saas_sdk/core/service_errors.py +19 -0
  6. geek_cafe_saas_sdk/core/service_result.py +121 -0
  7. geek_cafe_saas_sdk/decorators/__init__.py +64 -0
  8. geek_cafe_saas_sdk/decorators/auth.py +373 -0
  9. geek_cafe_saas_sdk/decorators/core.py +358 -0
  10. geek_cafe_saas_sdk/domains/__init__.py +0 -0
  11. geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
  12. geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
  13. geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
  14. geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
  15. geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
  16. geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
  17. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
  18. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
  19. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
  20. geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
  21. geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
  22. geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
  23. geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
  24. geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
  25. geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
  26. geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
  27. geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
  28. geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
  29. geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
  30. geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
  31. geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
  32. geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
  33. geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
  34. geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
  35. geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
  36. geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
  37. geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
  38. geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
  39. geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
  40. geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
  41. geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
  42. geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
  43. geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
  44. geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
  45. geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
  46. geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
  47. geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
  48. geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
  49. geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
  50. geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
  51. geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
  52. geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
  53. geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
  54. geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
  55. geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
  56. geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
  57. geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
  58. geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
  59. geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
  60. geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
  61. geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
  62. geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
  63. geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
  64. geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
  65. geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
  66. geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
  67. geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
  68. geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
  69. geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
  70. geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
  71. geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
  72. geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
  73. geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
  74. geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
  75. geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
  76. geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
  77. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
  78. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
  79. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
  80. geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
  81. geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
  82. geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
  83. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
  84. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
  85. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
  86. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
  87. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
  88. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
  89. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
  90. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
  91. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
  92. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
  93. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
  94. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
  95. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
  96. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
  97. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
  98. geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
  99. geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
  100. geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
  101. geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
  102. geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
  103. geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
  104. geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
  105. geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
  106. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
  107. geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
  108. geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
  109. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
  110. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
  111. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
  112. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
  113. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
  114. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
  115. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
  116. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
  117. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
  118. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
  119. geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
  120. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
  121. geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
  122. geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
  123. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
  124. geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
  125. geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
  126. geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
  127. geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
  128. geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
  129. geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
  130. geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
  131. geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
  132. geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
  133. geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
  134. geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
  135. geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
  136. geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
  137. geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
  138. geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
  139. geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
  140. geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
  141. geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
  142. geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
  143. geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
  144. geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
  145. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
  146. geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
  147. geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
  148. geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
  149. geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
  150. geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
  151. geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
  152. geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
  153. geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
  154. geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
  155. geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
  156. geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
  157. geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
  158. geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
  159. geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
  160. geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
  161. geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
  162. geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
  163. geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
  164. geek_cafe_saas_sdk/middleware/__init__.py +36 -0
  165. geek_cafe_saas_sdk/middleware/auth.py +85 -0
  166. geek_cafe_saas_sdk/middleware/authorization.py +523 -0
  167. geek_cafe_saas_sdk/middleware/cors.py +63 -0
  168. geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
  169. geek_cafe_saas_sdk/middleware/validation.py +80 -0
  170. geek_cafe_saas_sdk/models/__init__.py +20 -0
  171. geek_cafe_saas_sdk/models/base_model.py +233 -0
  172. geek_cafe_saas_sdk/services/__init__.py +18 -0
  173. geek_cafe_saas_sdk/services/database_service.py +441 -0
  174. geek_cafe_saas_sdk/utilities/__init__.py +88 -0
  175. geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
  176. geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
  177. geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
  178. geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
  179. geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
  180. geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
  181. geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
  182. geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
  183. geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
  184. geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
  185. geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
  186. geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
  187. geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
  188. geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
  189. geek_cafe_saas_sdk/utilities/response.py +209 -0
  190. geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
  191. geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
  192. geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
  193. geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
  194. geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
@@ -0,0 +1,491 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ ChatMessageService for managing individual chat messages.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, List
9
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
10
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
11
+ from .chat_channel_service import ChatChannelService
12
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
13
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
14
+ from geek_cafe_saas_sdk.domains.messaging.models import ChatMessage
15
+ from geek_cafe_saas_sdk.utilities.message_query_helper import MessageQueryHelper
16
+ import datetime as dt
17
+
18
+
19
+ class ChatMessageService(DatabaseService[ChatMessage]):
20
+ """Service for ChatMessage database operations."""
21
+
22
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None,
23
+ channel_service: ChatChannelService = None):
24
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
25
+ # Channel service for updating channel metadata
26
+ self.channel_service = channel_service or ChatChannelService(
27
+ dynamodb=dynamodb, table_name=table_name
28
+ )
29
+ # Query helper for sharded message reads
30
+ self.query_helper = MessageQueryHelper(dynamodb, table_name)
31
+
32
+ def create(self, tenant_id: str, user_id: str, payload: Dict[str, Any]) -> ServiceResult[ChatMessage]:
33
+ """
34
+ Create a new chat message from a payload.
35
+
36
+ Args:
37
+ tenant_id: Tenant ID
38
+ user_id: User ID creating the message
39
+ payload: Message data including channel_id, content
40
+
41
+ Returns:
42
+ ServiceResult with ChatMessage
43
+ """
44
+ try:
45
+ # Validate required fields
46
+ required_fields = ['channel_id', 'content']
47
+ self._validate_required_fields(payload, required_fields)
48
+
49
+ channel_id = payload['channel_id']
50
+
51
+ # Verify user has access to the channel and can post
52
+ channel_result = self.channel_service.get_by_id(channel_id, tenant_id, user_id)
53
+ if not channel_result.success:
54
+ return ServiceResult(
55
+ success=False,
56
+ error=channel_result.error,
57
+ error_code=channel_result.error_code
58
+ )
59
+
60
+ channel = channel_result.data
61
+
62
+ # Check if user can post to this channel
63
+ # For now, all members can post unless it's announcement-only
64
+ if not self.channel_service.is_member(channel_id, user_id):
65
+ raise AccessDeniedError("You must be a member to post to this channel")
66
+
67
+ if channel.is_announcement:
68
+ # TODO: Add admin check when user roles are implemented
69
+ # For now, only channel creator can post to announcement channels
70
+ if channel.created_by != user_id:
71
+ raise AccessDeniedError("Only admins can post to announcement channels")
72
+
73
+ # Create and map message instance from the payload
74
+ message = ChatMessage().map(payload)
75
+ message.tenant_id = tenant_id
76
+ message.user_id = user_id
77
+ message.created_by_id = user_id
78
+ message.channel_id = channel_id
79
+ message.sender_id = user_id
80
+ message.sender_name = payload.get('sender_name', '')
81
+
82
+ # Pass sharding config from channel for GSI1 key computation
83
+ if channel.is_sharded():
84
+ message._sharding_config = channel.sharding_config
85
+
86
+ # Handle parent message for threading
87
+ if message.parent_message_id:
88
+ # Verify parent message exists and belongs to same channel
89
+ parent_result = self.get_by_id(message.parent_message_id, tenant_id, user_id)
90
+ if not parent_result.success:
91
+ raise ValidationError("Parent message not found")
92
+
93
+ parent = parent_result.data
94
+ if parent.channel_id != channel_id:
95
+ raise ValidationError("Parent message must be in the same channel")
96
+
97
+ # Prepare for save (sets ID and timestamps)
98
+ message.prep_for_save()
99
+
100
+ # Save to database
101
+ save_result = self._save_model(message)
102
+ if not save_result.success:
103
+ return save_result
104
+
105
+ # Update channel's last message tracking
106
+ self.channel_service.update_last_message(
107
+ channel_id, tenant_id, message.id, message.created_utc_ts
108
+ )
109
+
110
+ # If this is a reply, increment parent's thread count
111
+ if message.parent_message_id:
112
+ self._increment_parent_thread_count(message.parent_message_id)
113
+
114
+ return save_result
115
+
116
+ except Exception as e:
117
+ return self._handle_service_exception(e, 'create_chat_message', tenant_id=tenant_id, user_id=user_id)
118
+
119
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[ChatMessage]:
120
+ """
121
+ Get chat message by ID with access control.
122
+
123
+ Args:
124
+ resource_id: Message ID
125
+ tenant_id: Tenant ID
126
+ user_id: User ID requesting access
127
+
128
+ Returns:
129
+ ServiceResult with ChatMessage
130
+ """
131
+ try:
132
+ message = self._get_model_by_id(resource_id, ChatMessage)
133
+
134
+ if not message:
135
+ raise NotFoundError(f"Chat message with ID {resource_id} not found")
136
+
137
+ # Check if deleted
138
+ if message.is_deleted():
139
+ raise NotFoundError(f"Chat message with ID {resource_id} not found")
140
+
141
+ # Validate tenant access
142
+ if hasattr(message, 'tenant_id'):
143
+ self._validate_tenant_access(message.tenant_id, tenant_id)
144
+
145
+ # Verify user has access to the channel
146
+ channel_result = self.channel_service.get_by_id(message.channel_id, tenant_id, user_id)
147
+ if not channel_result.success:
148
+ raise AccessDeniedError("Access denied to this message")
149
+
150
+ return ServiceResult.success_result(message)
151
+
152
+ except Exception as e:
153
+ return self._handle_service_exception(e, 'get_chat_message', resource_id=resource_id, tenant_id=tenant_id)
154
+
155
+ def list_by_channel(self, channel_id: str, tenant_id: str, user_id: str,
156
+ limit: int = 50, cursor: Optional[str] = None,
157
+ ascending: bool = False) -> ServiceResult[List[ChatMessage]]:
158
+ """
159
+ List messages in a channel with support for both normal and sharded channels.
160
+
161
+ Uses MessageQueryHelper to transparently handle:
162
+ - Normal channels (single partition)
163
+ - Sharded channels (multiple buckets/shards)
164
+
165
+ Args:
166
+ channel_id: Channel ID
167
+ tenant_id: Tenant ID
168
+ user_id: User ID for access control
169
+ limit: Maximum number of results (default 50)
170
+ cursor: Pagination cursor (base64 encoded, opaque)
171
+ ascending: Sort order (False = newest first, True = oldest first)
172
+
173
+ Returns:
174
+ ServiceResult with list of ChatMessages and pagination metadata
175
+ """
176
+ try:
177
+ # Verify user has access to the channel
178
+ channel_result = self.channel_service.get_by_id(channel_id, tenant_id, user_id)
179
+ if not channel_result.success:
180
+ return ServiceResult(
181
+ success=False,
182
+ error=channel_result.error,
183
+ error_code=channel_result.error_code
184
+ )
185
+
186
+ channel = channel_result.data
187
+
188
+ # Use MessageQueryHelper for sharding support
189
+ items, next_cursor = self.query_helper.query_messages(
190
+ channel_id=channel_id,
191
+ sharding_config=channel.sharding_config,
192
+ limit=limit,
193
+ cursor=cursor,
194
+ lookback_buckets=7 # Query last 7 days/hours of buckets
195
+ )
196
+
197
+ # Convert items to ChatMessage objects
198
+ messages = []
199
+ for item in items:
200
+ msg = ChatMessage().map(item)
201
+ # Filter out deleted messages
202
+ if not msg.is_deleted():
203
+ messages.append(msg)
204
+
205
+ # Handle ascending sort if requested (default is descending/newest first)
206
+ if ascending:
207
+ messages.reverse()
208
+
209
+ # Return with pagination cursor
210
+ response = ServiceResult.success_result(messages)
211
+ if next_cursor:
212
+ response.metadata = {"next_cursor": next_cursor}
213
+
214
+ return response
215
+
216
+ except Exception as e:
217
+ return self._handle_service_exception(e, 'list_messages_by_channel',
218
+ channel_id=channel_id, tenant_id=tenant_id)
219
+
220
+ def list_thread_replies(self, parent_message_id: str, tenant_id: str, user_id: str,
221
+ limit: int = 50) -> ServiceResult[List[ChatMessage]]:
222
+ """
223
+ List all replies to a parent message using GSI2.
224
+
225
+ Args:
226
+ parent_message_id: Parent message ID
227
+ tenant_id: Tenant ID
228
+ user_id: User ID for access control
229
+ limit: Maximum number of results
230
+
231
+ Returns:
232
+ ServiceResult with list of reply ChatMessages
233
+ """
234
+ try:
235
+ # Get parent message to verify access
236
+ parent_result = self.get_by_id(parent_message_id, tenant_id, user_id)
237
+ if not parent_result.success:
238
+ return ServiceResult(
239
+ success=False,
240
+ error=parent_result.error,
241
+ error_code=parent_result.error_code
242
+ )
243
+
244
+ temp_message = ChatMessage()
245
+ temp_message.parent_message_id = parent_message_id
246
+
247
+ result = self._query_by_index(
248
+ temp_message,
249
+ "gsi2",
250
+ ascending=True, # Oldest first for thread replies
251
+ limit=limit
252
+ )
253
+
254
+ if not result.success:
255
+ return result
256
+
257
+ # Filter out deleted messages
258
+ active_messages = [m for m in result.data if not m.is_deleted()]
259
+ return ServiceResult.success_result(active_messages)
260
+
261
+ except Exception as e:
262
+ return self._handle_service_exception(e, 'list_thread_replies',
263
+ parent_message_id=parent_message_id, tenant_id=tenant_id)
264
+
265
+ def list_by_sender(self, sender_id: str, tenant_id: str, user_id: str,
266
+ limit: int = 50) -> ServiceResult[List[ChatMessage]]:
267
+ """
268
+ List messages by sender using GSI3.
269
+
270
+ Args:
271
+ sender_id: Sender user ID
272
+ tenant_id: Tenant ID
273
+ user_id: User ID requesting (must be same as sender_id or admin)
274
+ limit: Maximum number of results
275
+
276
+ Returns:
277
+ ServiceResult with list of ChatMessages
278
+ """
279
+ try:
280
+ # Users can only see their own message history
281
+ # TODO: Add admin check when user roles are implemented
282
+ if sender_id != user_id:
283
+ raise AccessDeniedError("Cannot view other users' message history")
284
+
285
+ temp_message = ChatMessage()
286
+ temp_message.sender_id = sender_id
287
+
288
+ result = self._query_by_index(
289
+ temp_message,
290
+ "gsi3",
291
+ ascending=False, # Most recent first
292
+ limit=limit
293
+ )
294
+
295
+ if not result.success:
296
+ return result
297
+
298
+ # Filter by tenant and exclude deleted messages
299
+ user_messages = [
300
+ m for m in result.data
301
+ if not m.is_deleted() and m.tenant_id == tenant_id
302
+ ]
303
+
304
+ return ServiceResult.success_result(user_messages)
305
+
306
+ except Exception as e:
307
+ return self._handle_service_exception(e, 'list_messages_by_sender',
308
+ sender_id=sender_id, tenant_id=tenant_id)
309
+
310
+ def add_reaction(self, message_id: str, tenant_id: str, user_id: str,
311
+ emoji: str) -> ServiceResult[ChatMessage]:
312
+ """
313
+ Add a reaction to a message.
314
+
315
+ Args:
316
+ message_id: Message ID
317
+ tenant_id: Tenant ID
318
+ user_id: User ID adding the reaction
319
+ emoji: Emoji to add (e.g., "👍", "❤️")
320
+
321
+ Returns:
322
+ ServiceResult with updated ChatMessage
323
+ """
324
+ try:
325
+ message_result = self.get_by_id(message_id, tenant_id, user_id)
326
+ if not message_result.success:
327
+ return message_result
328
+
329
+ message = message_result.data
330
+ message.add_reaction(emoji, user_id)
331
+ message.updated_by_id = user_id
332
+ message.prep_for_save()
333
+
334
+ return self._save_model(message)
335
+
336
+ except Exception as e:
337
+ return self._handle_service_exception(e, 'add_message_reaction',
338
+ message_id=message_id, emoji=emoji)
339
+
340
+ def remove_reaction(self, message_id: str, tenant_id: str, user_id: str,
341
+ emoji: str) -> ServiceResult[ChatMessage]:
342
+ """
343
+ Remove a reaction from a message.
344
+
345
+ Args:
346
+ message_id: Message ID
347
+ tenant_id: Tenant ID
348
+ user_id: User ID removing the reaction
349
+ emoji: Emoji to remove
350
+
351
+ Returns:
352
+ ServiceResult with updated ChatMessage
353
+ """
354
+ try:
355
+ message_result = self.get_by_id(message_id, tenant_id, user_id)
356
+ if not message_result.success:
357
+ return message_result
358
+
359
+ message = message_result.data
360
+ message.remove_reaction(emoji, user_id)
361
+ message.updated_by_id = user_id
362
+ message.prep_for_save()
363
+
364
+ return self._save_model(message)
365
+
366
+ except Exception as e:
367
+ return self._handle_service_exception(e, 'remove_message_reaction',
368
+ message_id=message_id, emoji=emoji)
369
+
370
+ def update(self, resource_id: str, tenant_id: str, user_id: str,
371
+ updates: Dict[str, Any]) -> ServiceResult[ChatMessage]:
372
+ """
373
+ Update chat message with access control.
374
+
375
+ Args:
376
+ resource_id: Message ID
377
+ tenant_id: Tenant ID
378
+ user_id: User ID performing the update
379
+ updates: Dictionary of fields to update
380
+
381
+ Returns:
382
+ ServiceResult with updated ChatMessage
383
+ """
384
+ try:
385
+ message = self._get_model_by_id(resource_id, ChatMessage)
386
+
387
+ if not message:
388
+ raise NotFoundError(f"Chat message with ID {resource_id} not found")
389
+
390
+ # Validate tenant access
391
+ if hasattr(message, 'tenant_id'):
392
+ self._validate_tenant_access(message.tenant_id, tenant_id)
393
+
394
+ # Only the sender can edit their own message
395
+ if message.sender_id != user_id:
396
+ raise AccessDeniedError("Only the message sender can edit the message")
397
+
398
+ # Apply updates (only content can be edited)
399
+ if 'content' in updates:
400
+ message.content = updates['content']
401
+ message.mark_as_edited()
402
+
403
+ # Update metadata
404
+ message.updated_by_id = user_id
405
+ message.prep_for_save()
406
+
407
+ # Save updated message
408
+ return self._save_model(message)
409
+
410
+ except Exception as e:
411
+ return self._handle_service_exception(e, 'update_chat_message', resource_id=resource_id, tenant_id=tenant_id)
412
+
413
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
414
+ """
415
+ Soft delete chat message with access control.
416
+
417
+ Args:
418
+ resource_id: Message ID
419
+ tenant_id: Tenant ID
420
+ user_id: User ID performing the deletion
421
+
422
+ Returns:
423
+ ServiceResult with boolean success
424
+ """
425
+ try:
426
+ message = self._get_model_by_id(resource_id, ChatMessage)
427
+
428
+ if not message:
429
+ raise NotFoundError(f"Chat message with ID {resource_id} not found")
430
+
431
+ # Check if already deleted
432
+ if message.is_deleted():
433
+ return ServiceResult.success_result(True)
434
+
435
+ # Validate tenant access
436
+ if hasattr(message, 'tenant_id'):
437
+ self._validate_tenant_access(message.tenant_id, tenant_id)
438
+
439
+ # Only the sender can delete their own message
440
+ # TODO: Add channel admin check when user roles are implemented
441
+ if message.sender_id != user_id:
442
+ raise AccessDeniedError("Only the message sender can delete the message")
443
+
444
+ # Soft delete: set deleted timestamp and metadata
445
+ message.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
446
+ message.deleted_by_id = user_id
447
+ message.prep_for_save()
448
+
449
+ # Save the updated message
450
+ save_result = self._save_model(message)
451
+ if save_result.success:
452
+ return ServiceResult.success_result(True)
453
+ else:
454
+ return save_result
455
+
456
+ except Exception as e:
457
+ return self._handle_service_exception(e, 'delete_chat_message', resource_id=resource_id, tenant_id=tenant_id)
458
+
459
+ def _increment_parent_thread_count(self, parent_message_id: str):
460
+ """
461
+ Increment the thread count of a parent message.
462
+
463
+ Args:
464
+ parent_message_id: Parent message ID
465
+ """
466
+ try:
467
+ parent = self._get_model_by_id(parent_message_id, ChatMessage)
468
+ if parent:
469
+ parent.increment_thread_count()
470
+ parent.prep_for_save()
471
+ self._save_model(parent)
472
+ except Exception:
473
+ # Non-critical operation, just log and continue
474
+ pass
475
+
476
+ def _handle_service_exception(self, exception: Exception, operation: str, **context) -> ServiceResult:
477
+ """
478
+ Handle service exceptions with consistent error responses.
479
+
480
+ Delegates to parent class for proper error code mapping.
481
+
482
+ Args:
483
+ exception: The exception that occurred
484
+ operation: Name of the operation that failed
485
+ **context: Additional context for debugging
486
+
487
+ Returns:
488
+ ServiceResult with error details
489
+ """
490
+ # Use parent's exception handler for proper error code mapping
491
+ return super()._handle_service_exception(exception, operation, **context)