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,441 @@
|
|
|
1
|
+
# Database Service
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Generic, TypeVar, Dict, Any, List, Optional
|
|
5
|
+
from boto3_assist.dynamodb.dynamodb import DynamoDB
|
|
6
|
+
from ..core.service_result import ServiceResult
|
|
7
|
+
from ..core.service_errors import ValidationError, AccessDeniedError, NotFoundError
|
|
8
|
+
from ..core.error_codes import ErrorCode
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DatabaseService(ABC, Generic[T]):
|
|
15
|
+
"""Base service class for database operations."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
|
|
18
|
+
self.dynamodb = dynamodb or DynamoDB()
|
|
19
|
+
self.table_name = (
|
|
20
|
+
table_name or os.getenv("DYNAMODB_TABLE_NAME")
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if not self.table_name:
|
|
24
|
+
raise ValueError("Table name is required")
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def create(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[T]:
|
|
28
|
+
"""Create a new resource."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def get_by_id(
|
|
33
|
+
self, resource_id: str, tenant_id: str, user_id: str
|
|
34
|
+
) -> ServiceResult[T]:
|
|
35
|
+
"""Get resource by ID with access control."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def update(
|
|
40
|
+
self, resource_id: str, tenant_id: str, user_id: str, updates: Dict[str, Any]
|
|
41
|
+
) -> ServiceResult[T]:
|
|
42
|
+
"""Update resource with access control."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def delete(
|
|
47
|
+
self, resource_id: str, tenant_id: str, user_id: str
|
|
48
|
+
) -> ServiceResult[bool]:
|
|
49
|
+
"""Delete resource with access control."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _validate_required_fields(
|
|
53
|
+
self, data: Dict[str, Any], required_fields: List[str]
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Validate required fields are present."""
|
|
56
|
+
missing_fields = []
|
|
57
|
+
for field in required_fields:
|
|
58
|
+
if field not in data or data[field] is None:
|
|
59
|
+
missing_fields.append(field)
|
|
60
|
+
|
|
61
|
+
if missing_fields:
|
|
62
|
+
if len(missing_fields) == 1:
|
|
63
|
+
raise ValidationError(f"Field '{missing_fields[0]}' is required", missing_fields[0])
|
|
64
|
+
else:
|
|
65
|
+
field_list = "', '".join(missing_fields)
|
|
66
|
+
raise ValidationError(f"Fields '{field_list}' are required", missing_fields)
|
|
67
|
+
|
|
68
|
+
def _validate_owner_field(
|
|
69
|
+
self, payload: Dict[str, Any], authenticated_user_id: str, field_name: str = "owner_id"
|
|
70
|
+
) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Validate and resolve owner field following Rule #3.
|
|
73
|
+
|
|
74
|
+
Pattern:
|
|
75
|
+
- Missing owner_id: Default to authenticated user (self-service)
|
|
76
|
+
- Present owner_id with value: Use specified owner (admin-on-behalf)
|
|
77
|
+
- Present owner_id but empty/null: ERROR (explicit but invalid)
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
payload: Request payload
|
|
81
|
+
authenticated_user_id: User ID from JWT
|
|
82
|
+
field_name: Name of owner field (default: "owner_id")
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Resolved owner user ID
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValidationError: If owner_id is explicitly provided but empty/null
|
|
89
|
+
"""
|
|
90
|
+
# Check if field is explicitly provided in payload
|
|
91
|
+
if field_name in payload:
|
|
92
|
+
owner_id = payload[field_name]
|
|
93
|
+
# Explicit but empty/null = error (fail fast)
|
|
94
|
+
if not owner_id:
|
|
95
|
+
raise ValidationError(
|
|
96
|
+
f"{field_name} cannot be empty when explicitly provided",
|
|
97
|
+
field_name
|
|
98
|
+
)
|
|
99
|
+
return owner_id
|
|
100
|
+
|
|
101
|
+
# Field not provided = default to authenticated user (self-service)
|
|
102
|
+
return authenticated_user_id
|
|
103
|
+
|
|
104
|
+
def _validate_tenant_access(
|
|
105
|
+
self, resource_tenant_id: str, user_tenant_id: str
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Validate user has access to tenant resources."""
|
|
108
|
+
if resource_tenant_id != user_tenant_id:
|
|
109
|
+
raise AccessDeniedError("Access denied to resource in different tenant")
|
|
110
|
+
|
|
111
|
+
def _save_model(self, model: T) -> ServiceResult[T]:
|
|
112
|
+
"""Save model to database with enhanced error handling."""
|
|
113
|
+
try:
|
|
114
|
+
# The boto3_assist library handles all GSI key population automatically.
|
|
115
|
+
self.dynamodb.save(table_name=self.table_name, item=model)
|
|
116
|
+
return ServiceResult.success_result(model)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return ServiceResult.exception_result(
|
|
119
|
+
e,
|
|
120
|
+
error_code=ErrorCode.DATABASE_SAVE_FAILED,
|
|
121
|
+
context=f"Failed to save model to table {self.table_name}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _get_model_by_id(self, resource_id: str, model_class) -> Optional[T]:
|
|
125
|
+
"""Get model by ID from database."""
|
|
126
|
+
try:
|
|
127
|
+
# Create temporary model instance to get the primary key
|
|
128
|
+
temp_model = model_class()
|
|
129
|
+
temp_model.id = resource_id
|
|
130
|
+
key = temp_model.get_key("primary").key()
|
|
131
|
+
|
|
132
|
+
result = self.dynamodb.get(table_name=self.table_name, key=key)
|
|
133
|
+
if not result or "Item" not in result:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Create model instance from database result
|
|
137
|
+
model = model_class()
|
|
138
|
+
model.map(result["Item"])
|
|
139
|
+
|
|
140
|
+
return model
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _get_model_by_id_with_tenant_check(
|
|
145
|
+
self, resource_id: str, model_class, tenant_id: str
|
|
146
|
+
) -> Optional[T]:
|
|
147
|
+
"""
|
|
148
|
+
Get model by ID with automatic tenant validation.
|
|
149
|
+
|
|
150
|
+
This method provides tenant isolation security by ensuring that resources
|
|
151
|
+
can only be accessed within their own tenant. If the resource belongs to
|
|
152
|
+
a different tenant, it returns None (hiding its existence).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
resource_id: The resource ID to fetch
|
|
156
|
+
model_class: The model class to instantiate
|
|
157
|
+
tenant_id: The tenant ID from JWT (authenticated user's tenant)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The model if found and belongs to tenant, None otherwise
|
|
161
|
+
|
|
162
|
+
Security:
|
|
163
|
+
- Returns None for resources in different tenants (prevents enumeration)
|
|
164
|
+
- Returns None for deleted resources
|
|
165
|
+
- Single source of truth: tenant_id from JWT only
|
|
166
|
+
"""
|
|
167
|
+
model = self._get_model_by_id(resource_id, model_class)
|
|
168
|
+
|
|
169
|
+
if not model:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Tenant isolation: Only return resource if it belongs to user's tenant
|
|
173
|
+
if hasattr(model, 'tenant_id') and model.tenant_id != tenant_id:
|
|
174
|
+
# Return None instead of raising error to hide existence
|
|
175
|
+
# from users in other tenants (prevent enumeration attacks)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Hide deleted resources
|
|
179
|
+
if hasattr(model, 'is_deleted') and callable(model.is_deleted):
|
|
180
|
+
if model.is_deleted():
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return model
|
|
184
|
+
|
|
185
|
+
def _delete_model(self, model: T) -> ServiceResult[bool]:
|
|
186
|
+
"""Delete model from database with enhanced error handling."""
|
|
187
|
+
try:
|
|
188
|
+
primary_key = model.get_key("primary").key()
|
|
189
|
+
self.dynamodb.delete(table_name=self.table_name, primary_key=primary_key)
|
|
190
|
+
return ServiceResult.success_result(True)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return ServiceResult.exception_result(
|
|
193
|
+
e,
|
|
194
|
+
error_code=ErrorCode.DATABASE_DELETE_FAILED,
|
|
195
|
+
context=f"Failed to delete model from table {self.table_name}",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _query_by_index(
|
|
199
|
+
self,
|
|
200
|
+
model: T,
|
|
201
|
+
index_name: str,
|
|
202
|
+
*,
|
|
203
|
+
ascending: bool = False,
|
|
204
|
+
strongly_consistent: bool = False,
|
|
205
|
+
projection_expression: Optional[str] = None,
|
|
206
|
+
expression_attribute_names: Optional[dict] = None,
|
|
207
|
+
start_key: Optional[dict] = None,
|
|
208
|
+
limit: Optional[int] = None,
|
|
209
|
+
) -> ServiceResult[List[T]]:
|
|
210
|
+
"""
|
|
211
|
+
Generic query method for GSI queries with automatic model mapping.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
model: The pre-configured model instance to use for the query
|
|
215
|
+
index_name: The name of the GSI index to query
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
ServiceResult containing a list of mapped model instances.
|
|
219
|
+
Pagination info is included in error_details as 'last_evaluated_key' if more results exist.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
# Get the key for the specified index from the provided model
|
|
223
|
+
key = model.get_key(index_name).key()
|
|
224
|
+
|
|
225
|
+
# Execute the query
|
|
226
|
+
response = self.dynamodb.query(
|
|
227
|
+
table_name=self.table_name,
|
|
228
|
+
key=key,
|
|
229
|
+
index_name=index_name,
|
|
230
|
+
ascending=ascending,
|
|
231
|
+
strongly_consistent=strongly_consistent,
|
|
232
|
+
projection_expression=projection_expression,
|
|
233
|
+
expression_attribute_names=expression_attribute_names,
|
|
234
|
+
start_key=start_key,
|
|
235
|
+
limit=limit,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Extract items from response
|
|
239
|
+
data = response.get("Items", [])
|
|
240
|
+
|
|
241
|
+
# Map each item to a model instance
|
|
242
|
+
model_class = type(model)
|
|
243
|
+
items = [model_class().map(item) for item in data]
|
|
244
|
+
|
|
245
|
+
# Include pagination info if present
|
|
246
|
+
result = ServiceResult.success_result(items)
|
|
247
|
+
if "LastEvaluatedKey" in response:
|
|
248
|
+
result.error_details = {"last_evaluated_key": response["LastEvaluatedKey"]}
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
return ServiceResult.exception_result(
|
|
254
|
+
e,
|
|
255
|
+
error_code=ErrorCode.DATABASE_QUERY_FAILED,
|
|
256
|
+
context=f"Failed to query index {index_name} on table {self.table_name}",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _query_by_pk_with_sk_prefix(
|
|
260
|
+
self,
|
|
261
|
+
model_class: type,
|
|
262
|
+
pk: str,
|
|
263
|
+
sk_prefix: str,
|
|
264
|
+
*,
|
|
265
|
+
index_name: Optional[str] = None,
|
|
266
|
+
ascending: bool = True,
|
|
267
|
+
limit: Optional[int] = None,
|
|
268
|
+
start_key: Optional[dict] = None,
|
|
269
|
+
) -> ServiceResult[List[T]]:
|
|
270
|
+
"""
|
|
271
|
+
Query by partition key with sort key prefix (begins_with pattern).
|
|
272
|
+
|
|
273
|
+
This is useful for adjacent record patterns where multiple record types
|
|
274
|
+
are stored under the same partition key, distinguished by sort key prefix:
|
|
275
|
+
- pk="channel#123" AND sk BEGINS_WITH "member#" (all members)
|
|
276
|
+
- pk="channel#123" AND sk BEGINS_WITH "message#" (all messages)
|
|
277
|
+
- pk="user#456" AND sk BEGINS_WITH "session#" (all sessions)
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
model_class: The model class to map results to
|
|
281
|
+
pk: Partition key value
|
|
282
|
+
sk_prefix: Sort key prefix for begins_with condition
|
|
283
|
+
index_name: Index to query (None for primary index)
|
|
284
|
+
ascending: Sort order (True=ascending, False=descending)
|
|
285
|
+
limit: Maximum number of items to return
|
|
286
|
+
start_key: For pagination
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
ServiceResult containing list of mapped model instances
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
# Get all members in a channel
|
|
293
|
+
result = self._query_by_pk_with_sk_prefix(
|
|
294
|
+
model_class=ChatChannelMember,
|
|
295
|
+
pk="channel#channel_123",
|
|
296
|
+
sk_prefix="member#",
|
|
297
|
+
limit=100
|
|
298
|
+
)
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
# Build key condition expression
|
|
302
|
+
if index_name:
|
|
303
|
+
pk_attr = f"{index_name}_pk" if index_name.startswith("gsi") else "pk"
|
|
304
|
+
sk_attr = f"{index_name}_sk" if index_name.startswith("gsi") else "sk"
|
|
305
|
+
else:
|
|
306
|
+
pk_attr = "pk"
|
|
307
|
+
sk_attr = "sk"
|
|
308
|
+
|
|
309
|
+
# Use boto3 client for begins_with condition
|
|
310
|
+
query_params = {
|
|
311
|
+
"TableName": self.table_name,
|
|
312
|
+
"KeyConditionExpression": f"{pk_attr} = :pk AND begins_with({sk_attr}, :prefix)",
|
|
313
|
+
"ExpressionAttributeValues": {
|
|
314
|
+
":pk": pk,
|
|
315
|
+
":prefix": sk_prefix
|
|
316
|
+
},
|
|
317
|
+
"ScanIndexForward": ascending,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if index_name:
|
|
321
|
+
query_params["IndexName"] = index_name
|
|
322
|
+
if limit:
|
|
323
|
+
query_params["Limit"] = limit
|
|
324
|
+
if start_key:
|
|
325
|
+
query_params["ExclusiveStartKey"] = start_key
|
|
326
|
+
|
|
327
|
+
response = self.dynamodb.client.query(**query_params)
|
|
328
|
+
|
|
329
|
+
# Map results to model instances
|
|
330
|
+
items = [model_class().map(item) for item in response.get("Items", [])]
|
|
331
|
+
|
|
332
|
+
# Include pagination info
|
|
333
|
+
result = ServiceResult.success_result(items)
|
|
334
|
+
if "LastEvaluatedKey" in response:
|
|
335
|
+
result.error_details = {"last_evaluated_key": response["LastEvaluatedKey"]}
|
|
336
|
+
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
return ServiceResult.exception_result(
|
|
341
|
+
e,
|
|
342
|
+
error_code=ErrorCode.DATABASE_QUERY_FAILED,
|
|
343
|
+
context=f"Failed to query with pk prefix on table {self.table_name}",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _delete_by_composite_key(
|
|
347
|
+
self,
|
|
348
|
+
pk: str,
|
|
349
|
+
sk: str,
|
|
350
|
+
) -> ServiceResult[bool]:
|
|
351
|
+
"""
|
|
352
|
+
Delete an item by composite key (pk + sk).
|
|
353
|
+
|
|
354
|
+
Useful for adjacent record patterns where items use composite keys
|
|
355
|
+
instead of a single id field.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
pk: Partition key value
|
|
359
|
+
sk: Sort key value
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
ServiceResult[bool] - True if successful
|
|
363
|
+
|
|
364
|
+
Example:
|
|
365
|
+
# Delete a specific member from a channel
|
|
366
|
+
result = self._delete_by_composite_key(
|
|
367
|
+
pk="channel#channel_123",
|
|
368
|
+
sk="member#user_456"
|
|
369
|
+
)
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
# Use boto3 resource (simpler API that handles typing)
|
|
373
|
+
key = {"pk": pk, "sk": sk}
|
|
374
|
+
self.dynamodb.delete(table_name=self.table_name, primary_key=key)
|
|
375
|
+
return ServiceResult.success_result(True)
|
|
376
|
+
|
|
377
|
+
except Exception as e:
|
|
378
|
+
return ServiceResult.exception_result(
|
|
379
|
+
e,
|
|
380
|
+
error_code=ErrorCode.DATABASE_DELETE_FAILED,
|
|
381
|
+
context=f"Failed to delete item by composite key from table {self.table_name}",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def _handle_service_exception(
|
|
385
|
+
self, e: Exception, operation: str, **context
|
|
386
|
+
) -> ServiceResult[T]:
|
|
387
|
+
"""
|
|
388
|
+
Common exception handler for service operations.
|
|
389
|
+
|
|
390
|
+
Maps exception types to standardized error codes and formats error details.
|
|
391
|
+
Always includes operation name in error details for debugging.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
e: The exception that was raised
|
|
395
|
+
operation: Name of the operation that failed (for logging/debugging)
|
|
396
|
+
**context: Additional context information (resource_id, tenant_id, etc.)
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
ServiceResult with appropriate error information
|
|
400
|
+
"""
|
|
401
|
+
# Build base error details with operation
|
|
402
|
+
error_details = {"operation": operation, **context}
|
|
403
|
+
|
|
404
|
+
# Validation errors (4xx equivalent)
|
|
405
|
+
if isinstance(e, ValidationError):
|
|
406
|
+
field_info = getattr(e, "field", None)
|
|
407
|
+
# Handle both single field and list of fields
|
|
408
|
+
if isinstance(field_info, list):
|
|
409
|
+
error_details["fields"] = field_info
|
|
410
|
+
elif field_info:
|
|
411
|
+
error_details["field"] = field_info
|
|
412
|
+
|
|
413
|
+
return ServiceResult.error_result(
|
|
414
|
+
message=f"Validation failed: {str(e)}",
|
|
415
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
416
|
+
error_details=error_details,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Authorization errors (403 equivalent)
|
|
420
|
+
elif isinstance(e, AccessDeniedError):
|
|
421
|
+
return ServiceResult.error_result(
|
|
422
|
+
message=str(e),
|
|
423
|
+
error_code=ErrorCode.ACCESS_DENIED,
|
|
424
|
+
error_details=error_details
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Resource not found (404 equivalent)
|
|
428
|
+
elif isinstance(e, NotFoundError):
|
|
429
|
+
return ServiceResult.error_result(
|
|
430
|
+
message=str(e),
|
|
431
|
+
error_code=ErrorCode.NOT_FOUND,
|
|
432
|
+
error_details=error_details
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Unexpected errors (500 equivalent)
|
|
436
|
+
else:
|
|
437
|
+
return ServiceResult.exception_result(
|
|
438
|
+
exception=e,
|
|
439
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
440
|
+
context=f"Operation '{operation}' failed: {str(e)}"
|
|
441
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities package for geek-cafe-services.
|
|
3
|
+
|
|
4
|
+
This package contains utility functions and helpers that can be reused
|
|
5
|
+
across multiple Lambda functions and services.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .response import (
|
|
9
|
+
success_response,
|
|
10
|
+
error_response,
|
|
11
|
+
validation_error_response,
|
|
12
|
+
service_result_to_response,
|
|
13
|
+
json_snake_to_camel,
|
|
14
|
+
extract_path_parameters,
|
|
15
|
+
extract_query_parameters,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .custom_exceptions import (
|
|
19
|
+
Error,
|
|
20
|
+
DbFailures,
|
|
21
|
+
UnknownUserException,
|
|
22
|
+
UserAccountPermissionException,
|
|
23
|
+
UserAccountSubscriptionException,
|
|
24
|
+
SubscriptionException,
|
|
25
|
+
SecurityError,
|
|
26
|
+
TenancyStatusException,
|
|
27
|
+
SubscriptionDisabledException,
|
|
28
|
+
UnknownParameterService,
|
|
29
|
+
GeneralUserException,
|
|
30
|
+
InvalidHttpMethod,
|
|
31
|
+
InvalidRoutePath,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .http_status_code import HttpStatusCodes
|
|
35
|
+
|
|
36
|
+
from .environment_loader import (
|
|
37
|
+
EnvironmentLoader,
|
|
38
|
+
)
|
|
39
|
+
from .environment_variables import (
|
|
40
|
+
EnvironmentVariables,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from .lambda_event_utility import LambdaEventUtility
|
|
44
|
+
from .jwt_utility import JwtUtility
|
|
45
|
+
|
|
46
|
+
from .http_body_parameters import HttpBodyParameters
|
|
47
|
+
from .http_path_parameters import HttpPathParameters
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# Response utilities
|
|
51
|
+
"success_response",
|
|
52
|
+
"error_response",
|
|
53
|
+
"validation_error_response",
|
|
54
|
+
"service_result_to_response",
|
|
55
|
+
"json_snake_to_camel",
|
|
56
|
+
"extract_path_parameters",
|
|
57
|
+
"extract_query_parameters",
|
|
58
|
+
|
|
59
|
+
# Custom exceptions
|
|
60
|
+
"Error",
|
|
61
|
+
"DbFailures",
|
|
62
|
+
"UnknownUserException",
|
|
63
|
+
"UserAccountPermissionException",
|
|
64
|
+
"UserAccountSubscriptionException",
|
|
65
|
+
"SubscriptionException",
|
|
66
|
+
"SecurityError",
|
|
67
|
+
"TenancyStatusException",
|
|
68
|
+
"SubscriptionDisabledException",
|
|
69
|
+
"UnknownParameterService",
|
|
70
|
+
"GeneralUserException",
|
|
71
|
+
"InvalidHttpMethod",
|
|
72
|
+
"InvalidRoutePath",
|
|
73
|
+
|
|
74
|
+
# HTTP status codes
|
|
75
|
+
"HttpStatusCodes",
|
|
76
|
+
|
|
77
|
+
# Environment services
|
|
78
|
+
"EnvironmentLoader",
|
|
79
|
+
"EnvironmentVariables",
|
|
80
|
+
|
|
81
|
+
# Lambda event utilities
|
|
82
|
+
"LambdaEventUtility",
|
|
83
|
+
"JwtUtility",
|
|
84
|
+
|
|
85
|
+
# HTTP parameter utilities
|
|
86
|
+
"HttpBodyParameters",
|
|
87
|
+
"HttpPathParameters",
|
|
88
|
+
]
|