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,700 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ ChatChannelService for managing chat channels (Slack-like functionality).
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, List
9
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
10
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBKey
11
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
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 ChatChannel, ChatChannelMember
15
+ import datetime as dt
16
+
17
+
18
+ class ChatChannelService(DatabaseService[ChatChannel]):
19
+ """Service for ChatChannel database operations."""
20
+
21
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
22
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
23
+
24
+ def create(self, tenant_id: str, user_id: str, payload: Dict[str, Any]) -> ServiceResult[ChatChannel]:
25
+ """
26
+ Create a new chat channel from a payload.
27
+
28
+ Args:
29
+ tenant_id: Tenant ID
30
+ user_id: Owner user ID (who the channel belongs to)
31
+ payload: Channel data including:
32
+ - name: Channel name (required)
33
+ - created_by_id: Admin who created it (optional, for audit trail)
34
+
35
+ Returns:
36
+ ServiceResult with ChatChannel
37
+ """
38
+ try:
39
+ # Validate required fields
40
+ required_fields = ['name']
41
+ self._validate_required_fields(payload, required_fields)
42
+
43
+ # Create and map channel instance from the payload
44
+ channel = ChatChannel().map(payload)
45
+ channel.tenant_id = tenant_id
46
+ channel.user_id = user_id # Owner
47
+
48
+ # Set created_by from payload if provided (admin scenario), else use owner
49
+ if not channel.created_by_id:
50
+ channel.created_by_id = user_id
51
+ if not channel.created_by:
52
+ channel.created_by = user_id
53
+
54
+ # Set defaults
55
+ if not channel.channel_type:
56
+ channel.channel_type = "public"
57
+
58
+ # Prepare for save (sets ID and timestamps)
59
+ channel.prep_for_save()
60
+
61
+ # Save channel metadata
62
+ save_result = self._save_model(channel)
63
+ if not save_result.success:
64
+ return save_result
65
+
66
+ # Add creator as the first member
67
+ member_result = self._add_member_record(channel.id, user_id, user_id, role="owner")
68
+ if not member_result.success:
69
+ # Rollback channel creation if member add fails
70
+ # In production, consider using transactions
71
+ return member_result
72
+
73
+ channel.increment_member_count()
74
+ self._save_model(channel)
75
+
76
+ return ServiceResult.success_result(channel)
77
+
78
+ except Exception as e:
79
+ return self._handle_service_exception(e, 'create_chat_channel', tenant_id=tenant_id, user_id=user_id)
80
+
81
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[ChatChannel]:
82
+ """
83
+ Get chat channel by ID with access control.
84
+
85
+ Args:
86
+ resource_id: Channel ID
87
+ tenant_id: Tenant ID
88
+ user_id: User ID requesting access
89
+
90
+ Returns:
91
+ ServiceResult with ChatChannel
92
+ """
93
+ try:
94
+ channel = self._get_model_by_id(resource_id, ChatChannel)
95
+
96
+ if not channel:
97
+ raise NotFoundError(f"Chat channel with ID {resource_id} not found")
98
+
99
+ # Check if deleted
100
+ if channel.is_deleted():
101
+ raise NotFoundError(f"Chat channel with ID {resource_id} not found")
102
+
103
+ # Validate tenant access
104
+ if hasattr(channel, 'tenant_id'):
105
+ self._validate_tenant_access(channel.tenant_id, tenant_id)
106
+
107
+ # Check if user can access this channel
108
+ # Public channels: everyone can access
109
+ # Private channels: only members can access
110
+ if channel.channel_type == "private":
111
+ if not self.is_member(resource_id, user_id):
112
+ raise AccessDeniedError("Access denied to this private channel")
113
+
114
+ return ServiceResult.success_result(channel)
115
+
116
+ except Exception as e:
117
+ return self._handle_service_exception(e, 'get_chat_channel', resource_id=resource_id, tenant_id=tenant_id)
118
+
119
+ def list_by_type(self, tenant_id: str, channel_type: str, user_id: str,
120
+ limit: int = 50) -> ServiceResult[List[ChatChannel]]:
121
+ """
122
+ List chat channels by type using GSI1.
123
+
124
+ Args:
125
+ tenant_id: Tenant ID
126
+ channel_type: Channel type filter (public, private, direct)
127
+ user_id: User ID for access control
128
+ limit: Maximum number of results
129
+
130
+ Returns:
131
+ ServiceResult with list of ChatChannels
132
+ """
133
+ try:
134
+ temp_channel = ChatChannel()
135
+ temp_channel.tenant_id = tenant_id
136
+ temp_channel.channel_type = channel_type
137
+
138
+ result = self._query_by_index(
139
+ temp_channel,
140
+ "gsi1",
141
+ ascending=False, # Most recent activity first
142
+ limit=limit
143
+ )
144
+
145
+ if not result.success:
146
+ return result
147
+
148
+ # Filter: public channels visible to all, private only if member
149
+ accessible_channels = []
150
+ for channel in result.data:
151
+ if not channel.is_deleted():
152
+ if channel.channel_type == "public" or self.is_member(channel.id, user_id):
153
+ accessible_channels.append(channel)
154
+
155
+ return ServiceResult.success_result(accessible_channels)
156
+
157
+ except Exception as e:
158
+ return self._handle_service_exception(e, 'list_channels_by_type',
159
+ tenant_id=tenant_id, channel_type=channel_type)
160
+
161
+ def list_all(self, tenant_id: str, user_id: str, limit: int = 100) -> ServiceResult[List[ChatChannel]]:
162
+ """
163
+ List all chat channels for a tenant using GSI2.
164
+
165
+ Args:
166
+ tenant_id: Tenant ID
167
+ user_id: User ID for access control
168
+ limit: Maximum number of results
169
+
170
+ Returns:
171
+ ServiceResult with list of ChatChannels
172
+ """
173
+ try:
174
+ temp_channel = ChatChannel()
175
+ temp_channel.tenant_id = tenant_id
176
+
177
+ result = self._query_by_index(
178
+ temp_channel,
179
+ "gsi2",
180
+ ascending=True, # Alphabetical by name
181
+ limit=limit
182
+ )
183
+
184
+ if not result.success:
185
+ return result
186
+
187
+ # Filter accessible channels
188
+ accessible_channels = []
189
+ for channel in result.data:
190
+ if not channel.is_deleted():
191
+ if channel.channel_type == "public" or self.is_member(channel.id, user_id):
192
+ accessible_channels.append(channel)
193
+
194
+ return ServiceResult.success_result(accessible_channels)
195
+
196
+ except Exception as e:
197
+ return self._handle_service_exception(e, 'list_all_channels', tenant_id=tenant_id)
198
+
199
+ def list_default_channels(self, tenant_id: str, limit: int = 50) -> ServiceResult[List[ChatChannel]]:
200
+ """
201
+ List default channels (auto-join for new users) using GSI3.
202
+
203
+ Args:
204
+ tenant_id: Tenant ID
205
+ limit: Maximum number of results
206
+
207
+ Returns:
208
+ ServiceResult with list of default ChatChannels
209
+ """
210
+ try:
211
+ temp_channel = ChatChannel()
212
+ temp_channel.tenant_id = tenant_id
213
+ temp_channel.is_default = True
214
+
215
+ result = self._query_by_index(
216
+ temp_channel,
217
+ "gsi3",
218
+ ascending=False,
219
+ limit=limit
220
+ )
221
+
222
+ if not result.success:
223
+ return result
224
+
225
+ # Filter out deleted channels
226
+ active_channels = [c for c in result.data if not c.is_deleted()]
227
+ return ServiceResult.success_result(active_channels)
228
+
229
+ except Exception as e:
230
+ return self._handle_service_exception(e, 'list_default_channels', tenant_id=tenant_id)
231
+
232
+ def list_user_channels(self, tenant_id: str, user_id: str,
233
+ include_archived: bool = False, limit: int = 100) -> ServiceResult[List[ChatChannel]]:
234
+ """
235
+ List all channels a user is a member of.
236
+
237
+ Uses GSI1 for fast lookup via membership records.
238
+
239
+ Args:
240
+ tenant_id: Tenant ID
241
+ user_id: User ID
242
+ include_archived: Whether to include archived channels
243
+ limit: Maximum number of results
244
+
245
+ Returns:
246
+ ServiceResult with list of ChatChannels
247
+ """
248
+ try:
249
+ # Get user's channel memberships (fast via GSI1)
250
+ memberships_result = self.list_user_channels_fast(user_id, limit)
251
+ if not memberships_result.success:
252
+ return memberships_result
253
+
254
+ # Fetch full channel details for each membership
255
+ user_channels = []
256
+ for membership in memberships_result.data:
257
+ channel = self._get_model_by_id(membership["channel_id"], ChatChannel)
258
+ if channel and not channel.is_deleted():
259
+ # Filter by tenant and archived status
260
+ if channel.tenant_id == tenant_id:
261
+ if include_archived or not channel.is_archived:
262
+ user_channels.append(channel)
263
+
264
+ # Sort by last activity
265
+ user_channels.sort(key=lambda c: c.last_message_at or c.created_utc_ts, reverse=True)
266
+
267
+ return ServiceResult.success_result(user_channels)
268
+
269
+ except Exception as e:
270
+ return self._handle_service_exception(e, 'list_user_channels',
271
+ tenant_id=tenant_id, user_id=user_id)
272
+
273
+ def add_member(self, channel_id: str, tenant_id: str, user_id: str,
274
+ member_to_add: str) -> ServiceResult[ChatChannel]:
275
+ """
276
+ Add a member to a chat channel.
277
+
278
+ Args:
279
+ channel_id: Channel ID
280
+ tenant_id: Tenant ID
281
+ user_id: User ID performing the action
282
+ member_to_add: User ID to add as member
283
+
284
+ Returns:
285
+ ServiceResult with updated ChatChannel
286
+ """
287
+ try:
288
+ channel_result = self.get_by_id(channel_id, tenant_id, user_id)
289
+ if not channel_result.success:
290
+ return channel_result
291
+
292
+ channel = channel_result.data
293
+
294
+ # For private channels, only members can add new members
295
+ if channel.channel_type == "private" and not self.is_member(channel_id, user_id):
296
+ raise AccessDeniedError("Only members can add new members to private channels")
297
+
298
+ # Check if already a member
299
+ if self.is_member(channel_id, member_to_add):
300
+ return ServiceResult.success_result(channel) # Already a member, no-op
301
+
302
+ # Add member record
303
+ member_result = self._add_member_record(channel_id, member_to_add, user_id)
304
+ if not member_result.success:
305
+ return member_result
306
+
307
+ # Update member count
308
+ channel.increment_member_count()
309
+ channel.updated_by_id = user_id
310
+ channel.prep_for_save()
311
+ self._save_model(channel)
312
+
313
+ return ServiceResult.success_result(channel)
314
+
315
+ except Exception as e:
316
+ return self._handle_service_exception(e, 'add_channel_member',
317
+ channel_id=channel_id, member_to_add=member_to_add)
318
+
319
+ def remove_member(self, channel_id: str, tenant_id: str, user_id: str,
320
+ member_to_remove: str) -> ServiceResult[ChatChannel]:
321
+ """
322
+ Remove a member from a chat channel.
323
+
324
+ Args:
325
+ channel_id: Channel ID
326
+ tenant_id: Tenant ID
327
+ user_id: User ID performing the action
328
+ member_to_remove: User ID to remove
329
+
330
+ Returns:
331
+ ServiceResult with updated ChatChannel
332
+ """
333
+ try:
334
+ channel_result = self.get_by_id(channel_id, tenant_id, user_id)
335
+ if not channel_result.success:
336
+ return channel_result
337
+
338
+ channel = channel_result.data
339
+
340
+ # Users can remove themselves, or creator can remove others
341
+ if user_id != member_to_remove and channel.created_by != user_id:
342
+ raise AccessDeniedError("Only channel creator can remove other members")
343
+
344
+ # Check if member exists
345
+ if not self.is_member(channel_id, member_to_remove):
346
+ return ServiceResult.success_result(channel) # Not a member, no-op
347
+
348
+ # Delete member record using adjacent record pattern
349
+ pk = DynamoDBKey.build_key(("channel", channel_id))
350
+ sk = DynamoDBKey.build_key(("member", member_to_remove))
351
+
352
+ delete_result = self._delete_by_composite_key(pk=pk, sk=sk)
353
+ if not delete_result.success:
354
+ return delete_result
355
+
356
+ # Update member count
357
+ channel.decrement_member_count()
358
+ channel.updated_by_id = user_id
359
+ channel.prep_for_save()
360
+ self._save_model(channel)
361
+
362
+ return ServiceResult.success_result(channel)
363
+
364
+ except Exception as e:
365
+ return self._handle_service_exception(e, 'remove_channel_member',
366
+ channel_id=channel_id, member_to_remove=member_to_remove)
367
+
368
+ def update_last_message(self, channel_id: str, tenant_id: str,
369
+ message_id: str, timestamp: float) -> ServiceResult[ChatChannel]:
370
+ """
371
+ Update channel's last message tracking (called by ChatMessageService).
372
+
373
+ Args:
374
+ channel_id: Channel ID
375
+ tenant_id: Tenant ID
376
+ message_id: ID of the last message
377
+ timestamp: Timestamp of the message
378
+
379
+ Returns:
380
+ ServiceResult with updated ChatChannel
381
+ """
382
+ try:
383
+ channel = self._get_model_by_id(channel_id, ChatChannel)
384
+ if not channel:
385
+ raise NotFoundError(f"Chat channel with ID {channel_id} not found")
386
+
387
+ # Validate tenant
388
+ self._validate_tenant_access(channel.tenant_id, tenant_id)
389
+
390
+ channel.update_last_message(message_id, timestamp)
391
+ channel.prep_for_save()
392
+
393
+ return self._save_model(channel)
394
+
395
+ except Exception as e:
396
+ return self._handle_service_exception(e, 'update_channel_last_message',
397
+ channel_id=channel_id)
398
+
399
+ def archive(self, channel_id: str, tenant_id: str, user_id: str) -> ServiceResult[ChatChannel]:
400
+ """
401
+ Archive a chat channel.
402
+
403
+ Args:
404
+ channel_id: Channel ID
405
+ tenant_id: Tenant ID
406
+ user_id: User ID performing the action
407
+
408
+ Returns:
409
+ ServiceResult with updated ChatChannel
410
+ """
411
+ try:
412
+ channel_result = self.get_by_id(channel_id, tenant_id, user_id)
413
+ if not channel_result.success:
414
+ return channel_result
415
+
416
+ channel = channel_result.data
417
+
418
+ # Only creator can archive
419
+ if channel.created_by != user_id:
420
+ raise AccessDeniedError("Only channel creator can archive the channel")
421
+
422
+ channel.is_archived = True
423
+ channel.updated_by_id = user_id
424
+ channel.prep_for_save()
425
+
426
+ return self._save_model(channel)
427
+
428
+ except Exception as e:
429
+ return self._handle_service_exception(e, 'archive_channel', channel_id=channel_id)
430
+
431
+ def unarchive(self, channel_id: str, tenant_id: str, user_id: str) -> ServiceResult[ChatChannel]:
432
+ """
433
+ Unarchive a chat channel.
434
+
435
+ Args:
436
+ channel_id: Channel ID
437
+ tenant_id: Tenant ID
438
+ user_id: User ID performing the action
439
+
440
+ Returns:
441
+ ServiceResult with updated ChatChannel
442
+ """
443
+ try:
444
+ # For unarchive, we need to get the channel even if archived
445
+ channel = self._get_model_by_id(channel_id, ChatChannel)
446
+ if not channel:
447
+ raise NotFoundError(f"Chat channel with ID {channel_id} not found")
448
+
449
+ self._validate_tenant_access(channel.tenant_id, tenant_id)
450
+
451
+ # Only creator can unarchive
452
+ if channel.created_by != user_id:
453
+ raise AccessDeniedError("Only channel creator can unarchive the channel")
454
+
455
+ channel.is_archived = False
456
+ channel.updated_by_id = user_id
457
+ channel.prep_for_save()
458
+
459
+ return self._save_model(channel)
460
+
461
+ except Exception as e:
462
+ return self._handle_service_exception(e, 'unarchive_channel', channel_id=channel_id)
463
+
464
+ def update(self, resource_id: str, tenant_id: str, user_id: str,
465
+ updates: Dict[str, Any]) -> ServiceResult[ChatChannel]:
466
+ """
467
+ Update chat channel with access control.
468
+
469
+ Args:
470
+ resource_id: Channel ID
471
+ tenant_id: Tenant ID
472
+ user_id: User ID performing the update
473
+ updates: Dictionary of fields to update
474
+
475
+ Returns:
476
+ ServiceResult with updated ChatChannel
477
+ """
478
+ try:
479
+ channel = self._get_model_by_id(resource_id, ChatChannel)
480
+
481
+ if not channel:
482
+ raise NotFoundError(f"Chat channel with ID {resource_id} not found")
483
+
484
+ # Validate tenant access
485
+ if hasattr(channel, 'tenant_id'):
486
+ self._validate_tenant_access(channel.tenant_id, tenant_id)
487
+
488
+ # Check permissions - only creator or members can update
489
+ if not self.is_member(resource_id, user_id):
490
+ raise AccessDeniedError("Only channel members can update the channel")
491
+
492
+ # Apply updates (limited fields)
493
+ allowed_fields = ['name', 'description', 'topic', 'icon', 'is_announcement', 'is_default']
494
+ for field, value in updates.items():
495
+ if field in allowed_fields and hasattr(channel, field):
496
+ # Only creator can change certain settings
497
+ if field in ['is_announcement', 'is_default'] and channel.created_by != user_id:
498
+ continue
499
+ setattr(channel, field, value)
500
+
501
+ # Update metadata
502
+ channel.updated_by_id = user_id
503
+ channel.prep_for_save()
504
+
505
+ # Save updated channel
506
+ return self._save_model(channel)
507
+
508
+ except Exception as e:
509
+ return self._handle_service_exception(e, 'update_chat_channel', resource_id=resource_id, tenant_id=tenant_id)
510
+
511
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
512
+ """
513
+ Soft delete chat channel with access control.
514
+
515
+ Args:
516
+ resource_id: Channel ID
517
+ tenant_id: Tenant ID
518
+ user_id: User ID performing the deletion
519
+
520
+ Returns:
521
+ ServiceResult with boolean success
522
+ """
523
+ try:
524
+ channel = self._get_model_by_id(resource_id, ChatChannel)
525
+
526
+ if not channel:
527
+ raise NotFoundError(f"Chat channel with ID {resource_id} not found")
528
+
529
+ # Check if already deleted
530
+ if channel.is_deleted():
531
+ return ServiceResult.success_result(True)
532
+
533
+ # Validate tenant access
534
+ if hasattr(channel, 'tenant_id'):
535
+ self._validate_tenant_access(channel.tenant_id, tenant_id)
536
+
537
+ # Only creator can delete
538
+ if channel.created_by != user_id:
539
+ raise AccessDeniedError("Only channel creator can delete the channel")
540
+
541
+ # Soft delete: set deleted timestamp and metadata
542
+ channel.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
543
+ channel.deleted_by_id = user_id
544
+ channel.prep_for_save()
545
+
546
+ # Save the updated channel
547
+ save_result = self._save_model(channel)
548
+ if save_result.success:
549
+ return ServiceResult.success_result(True)
550
+ else:
551
+ return save_result
552
+
553
+ except Exception as e:
554
+ return self._handle_service_exception(e, 'delete_chat_channel', resource_id=resource_id, tenant_id=tenant_id)
555
+
556
+ # Membership Management Methods (using adjacent records for scalability)
557
+
558
+ def _add_member_record(self, channel_id: str, user_id: str, added_by_id: str,
559
+ role: str = "member") -> ServiceResult[ChatChannelMember]:
560
+ """
561
+ Add a member record to the channel (internal helper).
562
+
563
+ Args:
564
+ channel_id: Channel ID
565
+ user_id: User ID to add as member
566
+ added_by_id: User ID performing the add
567
+ role: Member role (owner, admin, member)
568
+
569
+ Returns:
570
+ ServiceResult with ChatChannelMember
571
+ """
572
+ try:
573
+ member = ChatChannelMember()
574
+ member.channel_id = channel_id
575
+ member.user_id = user_id
576
+ member.role = role
577
+ member.joined_at = dt.datetime.now(dt.UTC).timestamp()
578
+ member.added_by_id = added_by_id
579
+ member.tenant_id = None # Inherited from channel
580
+ member.prep_for_save()
581
+
582
+ return self._save_model(member)
583
+ except Exception as e:
584
+ return self._handle_service_exception(e, 'add_member_record',
585
+ channel_id=channel_id, user_id=user_id)
586
+
587
+ def is_member(self, channel_id: str, user_id: str) -> bool:
588
+ """
589
+ Check if a user is a member of a channel (fast lookup).
590
+ Uses adjacent record pattern: pk="channel#<id>", sk="member#<user_id>"
591
+
592
+ Args:
593
+ channel_id: Channel ID
594
+ user_id: User ID to check
595
+
596
+ Returns:
597
+ True if user is a member, False otherwise
598
+ """
599
+ try:
600
+ # Build composite key for adjacent record lookup
601
+ pk = DynamoDBKey.build_key(("channel", channel_id))
602
+ sk = DynamoDBKey.build_key(("member", user_id))
603
+ key = {"pk": pk, "sk": sk}
604
+
605
+ result = self.dynamodb.get(table_name=self.table_name, key=key)
606
+ return result and "Item" in result
607
+ except Exception:
608
+ return False
609
+
610
+ def get_member(self, channel_id: str, user_id: str) -> Optional[ChatChannelMember]:
611
+ """
612
+ Get member record for a user in a channel.
613
+ Uses adjacent record pattern: pk="channel#<id>", sk="member#<user_id>"
614
+
615
+ Args:
616
+ channel_id: Channel ID
617
+ user_id: User ID
618
+
619
+ Returns:
620
+ ChatChannelMember if found, None otherwise
621
+ """
622
+ try:
623
+ # Build composite key for adjacent record lookup
624
+ pk = DynamoDBKey.build_key(("channel", channel_id))
625
+ sk = DynamoDBKey.build_key(("member", user_id))
626
+ key = {"pk": pk, "sk": sk}
627
+
628
+ result = self.dynamodb.get(table_name=self.table_name, key=key)
629
+ if result and "Item" in result:
630
+ member_obj = ChatChannelMember()
631
+ member_obj.map(result["Item"])
632
+ return member_obj
633
+ return None
634
+ except Exception:
635
+ return None
636
+
637
+ def list_channel_members(self, channel_id: str, limit: int = 100) -> ServiceResult[List[ChatChannelMember]]:
638
+ """
639
+ List all members of a channel (paginated).
640
+
641
+ Args:
642
+ channel_id: Channel ID
643
+ limit: Maximum number of members to return
644
+
645
+ Returns:
646
+ ServiceResult with list of ChatChannelMembers
647
+ """
648
+ try:
649
+ # Query for all members in this channel using adjacent record pattern
650
+ pk = DynamoDBKey.build_key(("channel", channel_id))
651
+
652
+ return self._query_by_pk_with_sk_prefix(
653
+ model_class=ChatChannelMember,
654
+ pk=pk,
655
+ sk_prefix="member#",
656
+ limit=limit
657
+ )
658
+ except Exception as e:
659
+ return self._handle_service_exception(e, 'list_channel_members', channel_id=channel_id)
660
+
661
+ def list_user_channels_fast(self, user_id: str, limit: int = 100) -> ServiceResult[List[Dict[str, Any]]]:
662
+ """
663
+ List all channels a user is a member of (using GSI1 for fast lookup).
664
+
665
+ Args:
666
+ user_id: User ID
667
+ limit: Maximum number of channels to return
668
+
669
+ Returns:
670
+ ServiceResult with list of channel membership records
671
+ """
672
+ try:
673
+ # Query GSI1 for all channels this user is in
674
+ temp_member = ChatChannelMember()
675
+ temp_member.user_id = user_id
676
+
677
+ result = self._query_by_index(
678
+ model=temp_member,
679
+ index_name="gsi1",
680
+ limit=limit
681
+ )
682
+
683
+ if not result.success:
684
+ return result
685
+
686
+ # Extract channel info from memberships
687
+ memberships = []
688
+ for member in result.data:
689
+ memberships.append({
690
+ "channel_id": member.channel_id,
691
+ "role": member.role,
692
+ "joined_at": member.joined_at,
693
+ "last_read_at": member.last_read_at
694
+ })
695
+
696
+ return ServiceResult.success_result(memberships)
697
+ except Exception as e:
698
+ return self._handle_service_exception(e, 'list_user_channels_fast', user_id=user_id)
699
+
700
+ # Removed custom _handle_service_exception - using base class implementation from DatabaseService