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,64 @@
1
+ from aws_lambda_powertools import Logger
2
+ from geek_cafe_saas_sdk.utilities.environment_variables import (
3
+ EnvironmentVariables,
4
+ )
5
+
6
+
7
+ LOG_LEVEL = EnvironmentVariables.get_logging_level()
8
+
9
+
10
+ class LoggingUtility:
11
+ def __init__(self, service=None) -> None:
12
+ self.logger: Logger
13
+ self.logger = Logger(service=service)
14
+ self.logger.setLevel(LOG_LEVEL)
15
+
16
+ @staticmethod
17
+ def get_logger(
18
+ service: str | None = None, level: str | None | int = None
19
+ ) -> Logger:
20
+ if level is None:
21
+ level = LOG_LEVEL
22
+ logger = Logger(service=service)
23
+ logger.setLevel(level)
24
+ return logger
25
+
26
+ @staticmethod
27
+ def build_message(
28
+ source: str,
29
+ action: str,
30
+ message: str | None = None,
31
+ metric_filter: str | None = None,
32
+ ) -> dict:
33
+ """
34
+ Build a formatted message for logging
35
+ Args:
36
+ source (str): _description_
37
+ action (str): _description_
38
+ message (str, optional): _description_. Defaults to None.
39
+ metric_filter (str, optional): _description_. Defaults to None.
40
+
41
+ Returns:
42
+ dict: _description_
43
+ """
44
+ response = {
45
+ "source": source,
46
+ "action": action,
47
+ "details": message,
48
+ "metric_filter": metric_filter,
49
+ }
50
+ return response
51
+
52
+
53
+ class LogLevels:
54
+ def __init__(self) -> None:
55
+ pass
56
+
57
+ CRITICAL = 50
58
+ FATAL = CRITICAL
59
+ ERROR = 40
60
+ WARNING = 30
61
+ WARN = WARNING
62
+ INFO = 20
63
+ DEBUG = 10
64
+ NOTSET = 0
@@ -0,0 +1,340 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ MessageQueryHelper for querying messages from potentially sharded channels.
6
+
7
+ Handles:
8
+ - Single partition queries (normal channels)
9
+ - Multi-bucket queries (time-based partitioning)
10
+ - Multi-shard queries (hash-based partitioning)
11
+ - Merged sorted results (newest first)
12
+ - Stateless cursor pagination
13
+ """
14
+
15
+ from typing import List, Dict, Any, Optional, Tuple
16
+ from datetime import datetime, timedelta, timezone
17
+ import base64
18
+ import json
19
+ from boto3_assist.dynamodb.dynamodb_key import DynamoDBKey
20
+
21
+
22
+ class MessageQueryHelper:
23
+ """
24
+ Helper for querying messages from potentially sharded channels.
25
+
26
+ This class abstracts the complexity of querying across multiple
27
+ DynamoDB partitions when a channel uses time-bucketing and/or sharding.
28
+
29
+ For normal channels, it performs a simple single-partition query.
30
+ For sharded channels, it queries multiple partitions in parallel,
31
+ merges results by timestamp, and provides stateless cursor pagination.
32
+ """
33
+
34
+ def __init__(self, dynamodb, table_name: str):
35
+ """
36
+ Initialize the query helper.
37
+
38
+ Args:
39
+ dynamodb: DynamoDB client or resource
40
+ table_name: DynamoDB table name
41
+ """
42
+ # Handle both DynamoDB resource and client
43
+ if hasattr(dynamodb, 'client'):
44
+ # It's a DynamoDB resource (from boto3_assist)
45
+ self.dynamodb_client = dynamodb.client
46
+ self.table = dynamodb.resource.Table(table_name)
47
+ else:
48
+ # It's a client
49
+ self.dynamodb_client = dynamodb
50
+ self.table = None
51
+
52
+ self.table_name = table_name
53
+
54
+ def query_messages(
55
+ self,
56
+ channel_id: str,
57
+ sharding_config: Optional[Dict[str, Any]] = None,
58
+ limit: int = 50,
59
+ cursor: Optional[str] = None,
60
+ lookback_buckets: int = 7
61
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
62
+ """
63
+ Query messages from channel with optional sharding support.
64
+
65
+ Args:
66
+ channel_id: Channel ID to query
67
+ sharding_config: None for normal, config dict for sharded channels
68
+ limit: Max messages to return
69
+ cursor: Pagination cursor (base64 encoded)
70
+ lookback_buckets: How many time buckets to query (default 7 for daily)
71
+
72
+ Returns:
73
+ Tuple of (messages, next_cursor)
74
+ - messages: List of message dicts
75
+ - next_cursor: Opaque cursor string for next page, or None
76
+ """
77
+ if not sharding_config or not sharding_config.get("enabled"):
78
+ # Simple case: single partition query
79
+ return self._query_single_partition(channel_id, limit, cursor)
80
+
81
+ # Complex case: multi-bucket, multi-shard query
82
+ return self._query_multi_partition(
83
+ channel_id,
84
+ sharding_config,
85
+ limit,
86
+ cursor,
87
+ lookback_buckets
88
+ )
89
+
90
+ def _query_single_partition(
91
+ self,
92
+ channel_id: str,
93
+ limit: int,
94
+ cursor: Optional[str]
95
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
96
+ """
97
+ Query from single partition (normal channels).
98
+
99
+ Args:
100
+ channel_id: Channel ID
101
+ limit: Max messages
102
+ cursor: Pagination cursor
103
+
104
+ Returns:
105
+ Tuple of (messages, next_cursor)
106
+ """
107
+ pk_value = DynamoDBKey.build_key(("channel", channel_id))
108
+
109
+ # Parse cursor if provided
110
+ exclusive_start_key = None
111
+ if cursor:
112
+ try:
113
+ cursor_data = json.loads(base64.urlsafe_b64decode(cursor))
114
+ exclusive_start_key = cursor_data.get("key")
115
+ except Exception:
116
+ # Invalid cursor, ignore
117
+ pass
118
+
119
+ # Query GSI1
120
+ from boto3.dynamodb.conditions import Key
121
+
122
+ kwargs = {
123
+ "IndexName": "gsi1",
124
+ "KeyConditionExpression": Key("gsi1_pk").eq(pk_value),
125
+ "ScanIndexForward": False, # Newest first
126
+ "Limit": limit
127
+ }
128
+
129
+ if exclusive_start_key:
130
+ kwargs["ExclusiveStartKey"] = exclusive_start_key
131
+
132
+ # Use table if available, otherwise use client
133
+ if self.table:
134
+ response = self.table.query(**kwargs)
135
+ else:
136
+ # Convert to client format
137
+ kwargs_client = {
138
+ "TableName": self.table_name,
139
+ "IndexName": kwargs["IndexName"],
140
+ "KeyConditionExpression": "gsi1_pk = :pk",
141
+ "ExpressionAttributeValues": {":pk": pk_value},
142
+ "ScanIndexForward": kwargs["ScanIndexForward"],
143
+ "Limit": kwargs["Limit"]
144
+ }
145
+ if exclusive_start_key:
146
+ kwargs_client["ExclusiveStartKey"] = exclusive_start_key
147
+
148
+ response = self.dynamodb_client.query(**kwargs_client)
149
+
150
+ items = response.get("Items", [])
151
+
152
+ # Build next cursor
153
+ next_cursor = None
154
+ if response.get("LastEvaluatedKey"):
155
+ next_cursor = base64.urlsafe_b64encode(
156
+ json.dumps({"key": response["LastEvaluatedKey"]}, default=str).encode()
157
+ ).decode()
158
+
159
+ return items, next_cursor
160
+
161
+ def _query_multi_partition(
162
+ self,
163
+ channel_id: str,
164
+ sharding_config: Dict[str, Any],
165
+ limit: int,
166
+ cursor: Optional[str],
167
+ lookback_buckets: int
168
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
169
+ """
170
+ Query from multiple partitions (sharded channels).
171
+
172
+ Strategy:
173
+ 1. Generate list of partition keys (buckets × shards)
174
+ 2. Query each partition
175
+ 3. Merge results by timestamp (newest first)
176
+ 4. Apply limit
177
+ 5. Generate cursor from last emitted message
178
+
179
+ Args:
180
+ channel_id: Channel ID
181
+ sharding_config: Sharding configuration
182
+ limit: Max messages
183
+ cursor: Pagination cursor
184
+ lookback_buckets: Number of time buckets to query
185
+
186
+ Returns:
187
+ Tuple of (messages, next_cursor)
188
+ """
189
+ bucket_span = sharding_config.get("bucket_span", "day")
190
+ shard_count = sharding_config.get("shard_count", 1)
191
+
192
+ # Generate time buckets (newest to oldest)
193
+ now = datetime.now(timezone.utc)
194
+ buckets = self._generate_buckets(now, bucket_span, lookback_buckets)
195
+
196
+ # Parse cursor for guard SK (to avoid duplicates)
197
+ guard_sk = None
198
+ if cursor:
199
+ try:
200
+ cursor_data = json.loads(base64.urlsafe_b64decode(cursor))
201
+ guard_sk = cursor_data.get("last_sk")
202
+ except Exception:
203
+ # Invalid cursor, ignore
204
+ pass
205
+
206
+ # Query all partitions and collect items
207
+ all_items = []
208
+ for bucket in buckets:
209
+ for shard_idx in range(shard_count):
210
+ pk = self._build_sharded_pk(channel_id, bucket, shard_idx)
211
+ items = self._query_partition(pk, guard_sk, limit * 2) # Query extra for better merge
212
+ all_items.extend(items)
213
+
214
+ # Sort by timestamp descending (newest first)
215
+ # Handle both dict and object formats
216
+ def get_timestamp(item):
217
+ if isinstance(item, dict):
218
+ return item.get("created_utc_ts", 0)
219
+ return getattr(item, "created_utc_ts", 0)
220
+
221
+ all_items.sort(key=get_timestamp, reverse=True)
222
+
223
+ # Apply limit
224
+ result_items = all_items[:limit]
225
+
226
+ # Generate cursor from last item
227
+ next_cursor = None
228
+ if result_items and len(all_items) > limit:
229
+ last_item = result_items[-1]
230
+ # Get SK from last item
231
+ if isinstance(last_item, dict):
232
+ last_sk = last_item.get("gsi1_sk")
233
+ else:
234
+ last_sk = getattr(last_item, "gsi1_sk", None)
235
+
236
+ if last_sk:
237
+ next_cursor = base64.urlsafe_b64encode(
238
+ json.dumps({"last_sk": last_sk}).encode()
239
+ ).decode()
240
+
241
+ return result_items, next_cursor
242
+
243
+ def _query_partition(
244
+ self,
245
+ pk: str,
246
+ guard_sk: Optional[str],
247
+ limit: int
248
+ ) -> List[Dict[str, Any]]:
249
+ """
250
+ Query single partition with optional SK guard.
251
+
252
+ Args:
253
+ pk: Partition key value
254
+ guard_sk: Optional SK guard (for pagination)
255
+ limit: Max items to retrieve
256
+
257
+ Returns:
258
+ List of items from partition
259
+ """
260
+ from boto3.dynamodb.conditions import Key
261
+
262
+ kwargs = {
263
+ "IndexName": "gsi1",
264
+ "KeyConditionExpression": Key("gsi1_pk").eq(pk),
265
+ "ScanIndexForward": False, # Newest first
266
+ "Limit": limit
267
+ }
268
+
269
+ # Add SK filter if guard provided
270
+ if guard_sk:
271
+ kwargs["KeyConditionExpression"] &= Key("gsi1_sk").lt(guard_sk)
272
+
273
+ # Query
274
+ if self.table:
275
+ response = self.table.query(**kwargs)
276
+ else:
277
+ # Convert to client format
278
+ kwargs_client = {
279
+ "TableName": self.table_name,
280
+ "IndexName": kwargs["IndexName"],
281
+ "KeyConditionExpression": "gsi1_pk = :pk",
282
+ "ExpressionAttributeValues": {":pk": pk},
283
+ "ScanIndexForward": kwargs["ScanIndexForward"],
284
+ "Limit": kwargs["Limit"]
285
+ }
286
+
287
+ if guard_sk:
288
+ kwargs_client["KeyConditionExpression"] += " AND gsi1_sk < :guard"
289
+ kwargs_client["ExpressionAttributeValues"][":guard"] = guard_sk
290
+
291
+ response = self.dynamodb_client.query(**kwargs_client)
292
+
293
+ return response.get("Items", [])
294
+
295
+ def _build_sharded_pk(self, channel_id: str, bucket: str, shard_idx: int) -> str:
296
+ """
297
+ Build partition key for sharded channel.
298
+
299
+ Args:
300
+ channel_id: Channel ID
301
+ bucket: Time bucket string (yyyyMMdd or yyyyMMddHH)
302
+ shard_idx: Shard index
303
+
304
+ Returns:
305
+ Partition key string
306
+ """
307
+ return DynamoDBKey.build_key(
308
+ ("channel", channel_id),
309
+ ("bucket", bucket),
310
+ ("shard", str(shard_idx))
311
+ )
312
+
313
+ @staticmethod
314
+ def _generate_buckets(
315
+ start_time: datetime,
316
+ span: str,
317
+ count: int
318
+ ) -> List[str]:
319
+ """
320
+ Generate list of time bucket strings (newest to oldest).
321
+
322
+ Args:
323
+ start_time: Starting datetime (typically now)
324
+ span: "day" or "hour"
325
+ count: Number of buckets to generate
326
+
327
+ Returns:
328
+ List of bucket strings (e.g., ["20251014", "20251013", ...])
329
+ """
330
+ buckets = []
331
+ delta = timedelta(days=1) if span == "day" else timedelta(hours=1)
332
+
333
+ for i in range(count):
334
+ bucket_time = start_time - (i * delta)
335
+ bucket_str = bucket_time.strftime(
336
+ "%Y%m%d" if span == "day" else "%Y%m%d%H"
337
+ )
338
+ buckets.append(bucket_str)
339
+
340
+ return buckets
@@ -0,0 +1,209 @@
1
+ """
2
+ Utility functions for creating standardized Lambda responses.
3
+ """
4
+
5
+ import json
6
+ from typing import Any, Dict, Optional
7
+ from datetime import datetime, UTC
8
+ from boto3_assist.utilities.serialization_utility import JsonConversions
9
+ from typing import Union, List
10
+ from ..core.error_codes import ErrorCode
11
+
12
+
13
+ def json_snake_to_camel(
14
+ payload: Union[List[Dict[str, Any]], Dict[str, Any], None],
15
+ ) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
16
+ """
17
+ Convert backend data from snake_case to camelCase for UI consumption.
18
+
19
+ Args:
20
+ payload: The backend data in snake_case format (dict or list of dicts)
21
+
22
+ Returns:
23
+ The payload converted to camelCase format, maintaining the same structure
24
+
25
+ Raises:
26
+ ValueError: If the payload is None
27
+ """
28
+ if payload is None:
29
+ raise ValueError("Payload cannot be None")
30
+ if not payload:
31
+ return payload # Return empty dict/list as-is
32
+
33
+ return JsonConversions.json_snake_to_camel(payload)
34
+
35
+
36
+ def success_response(
37
+ data: Any, status_code: int = 200, message: Optional[str] = None
38
+ ) -> Dict[str, Any]:
39
+ """
40
+ Create a successful API Gateway response with automatic camelCase conversion.
41
+
42
+ Args:
43
+ data: Response data to include in body (will be converted to camelCase)
44
+ status_code: HTTP status code (default: 200)
45
+ message: Optional success message
46
+
47
+ Returns:
48
+ API Gateway response dictionary
49
+ """
50
+ # Convert data to camelCase for UI consumption
51
+ ui_data = json_snake_to_camel(data) if data is not None and data != {} else data
52
+
53
+ body = {
54
+ "data": ui_data,
55
+ "timestamp": datetime.now(UTC).isoformat(),
56
+ "status_code": status_code,
57
+ "success": True,
58
+ }
59
+
60
+
61
+ if message:
62
+ body["message"] = message
63
+
64
+ return {
65
+ "statusCode": status_code,
66
+ "headers": {
67
+ "Content-Type": "application/json",
68
+ "Access-Control-Allow-Origin": "*",
69
+ },
70
+ "body": json.dumps(body, default=str),
71
+ }
72
+
73
+
74
+ def error_response(
75
+ error: str, error_code: str, status_code: int = 400
76
+ ) -> Dict[str, Any]:
77
+ """
78
+ Create an error API Gateway response.
79
+
80
+ Args:
81
+ error: Error message
82
+ error_code: Standardized error code
83
+ status_code: HTTP status code (default: 400)
84
+
85
+ Returns:
86
+ API Gateway response dictionary
87
+ """
88
+
89
+ body = {
90
+ "error": error,
91
+ "error_code": error_code,
92
+ "timestamp": datetime.now(UTC).isoformat(),
93
+ "status_code": status_code,
94
+ "success": False,
95
+ }
96
+
97
+ body = json_snake_to_camel(body)
98
+
99
+ return {
100
+ "statusCode": status_code,
101
+ "headers": {
102
+ "Content-Type": "application/json",
103
+ "Access-Control-Allow-Origin": "*",
104
+ },
105
+ "body": json.dumps(body, default=str),
106
+ }
107
+
108
+
109
+ def validation_error_response(error: str, status_code: int = 400) -> Dict[str, Any]:
110
+ """
111
+ Create a validation error response.
112
+
113
+ Args:
114
+ error: Validation error message
115
+ status_code: HTTP status code (default: 400)
116
+
117
+ Returns:
118
+ API Gateway response dictionary
119
+ """
120
+ return error_response(error, "VALIDATION_ERROR", status_code)
121
+
122
+
123
+ def service_result_to_response(result, success_status: int = 200) -> Dict[str, Any]:
124
+ """
125
+ Convert a ServiceResult to an API Gateway response.
126
+
127
+ Args:
128
+ result: ServiceResult object from service layer
129
+ success_status: HTTP status code for successful operations
130
+
131
+ Returns:
132
+ API Gateway response dictionary
133
+ """
134
+ if result.success:
135
+ # Handle model serialization for different data types
136
+ data = result.data
137
+ if hasattr(data, 'to_dictionary'):
138
+ # Single model object
139
+ data = data.to_dictionary()
140
+ elif isinstance(data, list) and data and hasattr(data[0], 'to_dictionary'):
141
+ # List of model objects
142
+ data = [item.to_dictionary() for item in data]
143
+
144
+ return success_response(data, success_status)
145
+ else:
146
+ # Get HTTP status code from ErrorCode enum (or use default mapping)
147
+ try:
148
+ # Try to convert string error code to ErrorCode enum
149
+ error_code_enum = ErrorCode(result.error_code) if result.error_code else None
150
+ status_code = ErrorCode.get_http_status(error_code_enum) if error_code_enum else 400
151
+ except ValueError:
152
+ # Fallback for unknown error codes
153
+ legacy_map = {
154
+ "DUPLICATE_NAME": 409,
155
+ "DUPLICATE_ITEM": 409,
156
+ "GROUP_NOT_FOUND": 404,
157
+ }
158
+ status_code = legacy_map.get(result.error_code, 400)
159
+
160
+ # Create structured error response with nested structure
161
+ error_data = {
162
+ "message": result.message,
163
+ "code": result.error_code,
164
+ "details": result.error_details
165
+ }
166
+
167
+ body = {
168
+ "error": error_data,
169
+ "timestamp": datetime.now(UTC).isoformat(),
170
+ "status_code": status_code,
171
+ "success": False,
172
+ }
173
+
174
+ body = json_snake_to_camel(body)
175
+
176
+ return {
177
+ "statusCode": status_code,
178
+ "headers": {
179
+ "Content-Type": "application/json",
180
+ "Access-Control-Allow-Origin": "*",
181
+ },
182
+ "body": json.dumps(body, default=str),
183
+ }
184
+
185
+
186
+ def extract_path_parameters(event: Dict[str, Any]) -> Dict[str, str]:
187
+ """
188
+ Extract path parameters from API Gateway event.
189
+
190
+ Args:
191
+ event: API Gateway event
192
+
193
+ Returns:
194
+ Dictionary of path parameters
195
+ """
196
+ return event.get("pathParameters") or {}
197
+
198
+
199
+ def extract_query_parameters(event: Dict[str, Any]) -> Dict[str, str]:
200
+ """
201
+ Extract query string parameters from API Gateway event.
202
+
203
+ Args:
204
+ event: API Gateway event
205
+
206
+ Returns:
207
+ Dictionary of query parameters
208
+ """
209
+ return event.get("queryStringParameters") or {}