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,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
+ ]