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.
- geek_cafe_saas_sdk/__init__.py +9 -0
- geek_cafe_saas_sdk/core/__init__.py +11 -0
- geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
- geek_cafe_saas_sdk/core/error_codes.py +132 -0
- geek_cafe_saas_sdk/core/service_errors.py +19 -0
- geek_cafe_saas_sdk/core/service_result.py +121 -0
- geek_cafe_saas_sdk/decorators/__init__.py +64 -0
- geek_cafe_saas_sdk/decorators/auth.py +373 -0
- geek_cafe_saas_sdk/decorators/core.py +358 -0
- geek_cafe_saas_sdk/domains/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
- geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
- geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
- geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
- geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
- geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
- geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
- geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
- geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
- geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
- geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
- geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
- geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
- geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
- geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
- geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
- geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
- geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
- geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
- geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
- geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
- geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
- geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
- geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
- geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
- geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
- geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
- geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
- geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
- geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
- geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
- geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
- geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
- geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
- geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
- geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
- geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
- geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
- geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
- geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
- geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
- geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
- geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
- geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
- geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
- geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
- geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
- geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
- geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
- geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
- geek_cafe_saas_sdk/middleware/__init__.py +36 -0
- geek_cafe_saas_sdk/middleware/auth.py +85 -0
- geek_cafe_saas_sdk/middleware/authorization.py +523 -0
- geek_cafe_saas_sdk/middleware/cors.py +63 -0
- geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
- geek_cafe_saas_sdk/middleware/validation.py +80 -0
- geek_cafe_saas_sdk/models/__init__.py +20 -0
- geek_cafe_saas_sdk/models/base_model.py +233 -0
- geek_cafe_saas_sdk/services/__init__.py +18 -0
- geek_cafe_saas_sdk/services/database_service.py +441 -0
- geek_cafe_saas_sdk/utilities/__init__.py +88 -0
- geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
- geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
- geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
- geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
- geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
- geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
- geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
- geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
- geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
- geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
- geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
- geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
- geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
- geek_cafe_saas_sdk/utilities/response.py +209 -0
- geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
- 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)
|