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,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 {}
|