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,497 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ ContactThreadService for managing contact threads and support tickets.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, List
9
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
10
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
11
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
12
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
13
+ from geek_cafe_saas_sdk.domains.messaging.models import ContactThread
14
+ import datetime as dt
15
+
16
+
17
+ class ContactThreadService(DatabaseService[ContactThread]):
18
+ """Service for ContactThread database operations."""
19
+
20
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
21
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
22
+
23
+ def create(self, tenant_id: str, user_id: str, payload: Dict[str, Any]) -> ServiceResult[ContactThread]:
24
+ """
25
+ Create a new contact thread from a payload.
26
+
27
+ Args:
28
+ tenant_id: Tenant ID
29
+ user_id: User ID creating the thread (can be guest session ID)
30
+ payload: Contact thread data including subject, sender, initial_message
31
+
32
+ Returns:
33
+ ServiceResult with ContactThread
34
+ """
35
+ try:
36
+ # Validate required fields
37
+ required_fields = ['subject', 'sender']
38
+ self._validate_required_fields(payload, required_fields)
39
+
40
+ # Validate sender has required fields
41
+ sender = payload.get('sender', {})
42
+ if not sender.get('id'):
43
+ raise ValidationError("Sender must have an 'id' field")
44
+
45
+ # Create and map thread instance from the payload
46
+ thread = ContactThread().map(payload)
47
+ thread.tenant_id = tenant_id
48
+ thread.user_id = user_id
49
+ thread.created_by_id = user_id
50
+
51
+ # Set defaults
52
+ if not thread.status:
53
+ thread.status = "open"
54
+ if not thread.priority:
55
+ thread.priority = "medium"
56
+ if not thread.inbox_id:
57
+ thread.inbox_id = "support"
58
+
59
+ # Add initial message if provided
60
+ if 'initial_message' in payload and payload['initial_message']:
61
+ initial_msg = {
62
+ "id": f"msg_{dt.datetime.now(dt.UTC).timestamp()}",
63
+ "content": payload['initial_message'],
64
+ "sender_id": sender.get('id'),
65
+ "sender_name": sender.get('name', 'Guest'),
66
+ "is_staff_reply": False,
67
+ "created_at": dt.datetime.now(dt.UTC).timestamp()
68
+ }
69
+ thread.add_message(initial_msg)
70
+
71
+ # Prepare for save (sets ID and timestamps)
72
+ thread.prep_for_save()
73
+
74
+ # Save to database
75
+ return self._save_model(thread)
76
+
77
+ except Exception as e:
78
+ return self._handle_service_exception(e, 'create_contact_thread', tenant_id=tenant_id, user_id=user_id)
79
+
80
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str,
81
+ user_inboxes: List[str] = None) -> ServiceResult[ContactThread]:
82
+ """
83
+ Get contact thread by ID with access control.
84
+
85
+ Args:
86
+ resource_id: Thread ID
87
+ tenant_id: Tenant ID
88
+ user_id: User ID requesting access
89
+ user_inboxes: List of inbox IDs the user has access to
90
+
91
+ Returns:
92
+ ServiceResult with ContactThread
93
+ """
94
+ try:
95
+ thread = self._get_model_by_id(resource_id, ContactThread)
96
+
97
+ if not thread:
98
+ raise NotFoundError(f"Contact thread with ID {resource_id} not found")
99
+
100
+ # Check if deleted
101
+ if thread.is_deleted():
102
+ raise NotFoundError(f"Contact thread with ID {resource_id} not found")
103
+
104
+ # Validate tenant access
105
+ if hasattr(thread, 'tenant_id'):
106
+ self._validate_tenant_access(thread.tenant_id, tenant_id)
107
+
108
+ # Check if user can access this thread
109
+ if not thread.can_user_access(user_id, user_inboxes or []):
110
+ raise AccessDeniedError("Access denied to this contact thread")
111
+
112
+ return ServiceResult.success_result(thread)
113
+
114
+ except Exception as e:
115
+ return self._handle_service_exception(e, 'get_contact_thread', resource_id=resource_id, tenant_id=tenant_id)
116
+
117
+ def list_by_inbox_and_status(self, inbox_id: str, status: str, tenant_id: str,
118
+ priority: str = None, limit: int = 50) -> ServiceResult[List[ContactThread]]:
119
+ """
120
+ List contact threads by inbox and status using GSI1.
121
+
122
+ Args:
123
+ inbox_id: Inbox ID (support, sales, etc.)
124
+ status: Status filter (open, in_progress, resolved, closed)
125
+ tenant_id: Tenant ID for filtering
126
+ priority: Optional priority filter
127
+ limit: Maximum number of results
128
+
129
+ Returns:
130
+ ServiceResult with list of ContactThreads
131
+ """
132
+ try:
133
+ # Create a temporary thread instance to get the GSI key
134
+ temp_thread = ContactThread()
135
+ temp_thread.inbox_id = inbox_id
136
+ temp_thread.status = status
137
+ if priority:
138
+ temp_thread.priority = priority
139
+
140
+ # Query by GSI1 (inbox + status), sorted by priority and timestamp
141
+ result = self._query_by_index(
142
+ temp_thread,
143
+ "gsi1",
144
+ ascending=False, # Most recent first
145
+ limit=limit
146
+ )
147
+
148
+ if not result.success:
149
+ return result
150
+
151
+ # Filter by tenant and exclude deleted threads
152
+ active_threads = [
153
+ t for t in result.data
154
+ if not t.is_deleted() and t.tenant_id == tenant_id
155
+ ]
156
+
157
+ return ServiceResult.success_result(active_threads)
158
+
159
+ except Exception as e:
160
+ return self._handle_service_exception(e, 'list_by_inbox_and_status',
161
+ inbox_id=inbox_id, status=status, tenant_id=tenant_id)
162
+
163
+ def list_by_tenant_and_status(self, tenant_id: str, status: str,
164
+ limit: int = 50) -> ServiceResult[List[ContactThread]]:
165
+ """
166
+ List contact threads by tenant and status using GSI2.
167
+
168
+ Args:
169
+ tenant_id: Tenant ID
170
+ status: Status filter
171
+ limit: Maximum number of results
172
+
173
+ Returns:
174
+ ServiceResult with list of ContactThreads
175
+ """
176
+ try:
177
+ temp_thread = ContactThread()
178
+ temp_thread.tenant_id = tenant_id
179
+ temp_thread.status = status
180
+
181
+ result = self._query_by_index(
182
+ temp_thread,
183
+ "gsi2",
184
+ ascending=False,
185
+ limit=limit
186
+ )
187
+
188
+ if not result.success:
189
+ return result
190
+
191
+ # Filter out deleted threads
192
+ active_threads = [t for t in result.data if not t.is_deleted()]
193
+ return ServiceResult.success_result(active_threads)
194
+
195
+ except Exception as e:
196
+ return self._handle_service_exception(e, 'list_by_tenant_and_status',
197
+ tenant_id=tenant_id, status=status)
198
+
199
+ def list_by_assigned_user(self, assigned_to: str, tenant_id: str,
200
+ status: str = None, limit: int = 50) -> ServiceResult[List[ContactThread]]:
201
+ """
202
+ List contact threads assigned to a specific user using GSI3.
203
+
204
+ Args:
205
+ assigned_to: Staff user ID
206
+ tenant_id: Tenant ID for filtering
207
+ status: Optional status filter
208
+ limit: Maximum number of results
209
+
210
+ Returns:
211
+ ServiceResult with list of ContactThreads
212
+ """
213
+ try:
214
+ temp_thread = ContactThread()
215
+ temp_thread.assigned_to = assigned_to
216
+ if status:
217
+ temp_thread.status = status
218
+
219
+ result = self._query_by_index(
220
+ temp_thread,
221
+ "gsi3",
222
+ ascending=False,
223
+ limit=limit
224
+ )
225
+
226
+ if not result.success:
227
+ return result
228
+
229
+ # Filter by tenant and exclude deleted threads
230
+ active_threads = [
231
+ t for t in result.data
232
+ if not t.is_deleted() and t.tenant_id == tenant_id
233
+ ]
234
+
235
+ return ServiceResult.success_result(active_threads)
236
+
237
+ except Exception as e:
238
+ return self._handle_service_exception(e, 'list_by_assigned_user',
239
+ assigned_to=assigned_to, tenant_id=tenant_id)
240
+
241
+ def list_by_sender_email(self, sender_email: str, tenant_id: str,
242
+ limit: int = 50) -> ServiceResult[List[ContactThread]]:
243
+ """
244
+ List all contact threads from a specific sender email using GSI5.
245
+
246
+ Args:
247
+ sender_email: Sender's email address
248
+ tenant_id: Tenant ID for filtering
249
+ limit: Maximum number of results
250
+
251
+ Returns:
252
+ ServiceResult with list of ContactThreads
253
+ """
254
+ try:
255
+ temp_thread = ContactThread()
256
+ temp_thread.sender = {"email": sender_email}
257
+
258
+ result = self._query_by_index(
259
+ temp_thread,
260
+ "gsi5",
261
+ ascending=False,
262
+ limit=limit
263
+ )
264
+
265
+ if not result.success:
266
+ return result
267
+
268
+ # Filter by tenant and exclude deleted threads
269
+ active_threads = [
270
+ t for t in result.data
271
+ if not t.is_deleted() and t.tenant_id == tenant_id
272
+ ]
273
+
274
+ return ServiceResult.success_result(active_threads)
275
+
276
+ except Exception as e:
277
+ return self._handle_service_exception(e, 'list_by_sender_email',
278
+ sender_email=sender_email, tenant_id=tenant_id)
279
+
280
+ def add_message(self, thread_id: str, tenant_id: str, user_id: str,
281
+ message_data: Dict[str, Any], user_inboxes: List[str] = None) -> ServiceResult[ContactThread]:
282
+ """
283
+ Add a message to an existing contact thread.
284
+
285
+ Args:
286
+ thread_id: Thread ID
287
+ tenant_id: Tenant ID
288
+ user_id: User ID posting the message
289
+ message_data: Message data including content, sender_name, is_staff_reply
290
+ user_inboxes: List of inbox IDs the user has access to
291
+
292
+ Returns:
293
+ ServiceResult with updated ContactThread
294
+ """
295
+ try:
296
+ # Get the thread
297
+ thread_result = self.get_by_id(thread_id, tenant_id, user_id, user_inboxes)
298
+ if not thread_result.success:
299
+ return thread_result
300
+
301
+ thread = thread_result.data
302
+
303
+ # Create the message
304
+ message = {
305
+ "id": message_data.get("id", f"msg_{dt.datetime.now(dt.UTC).timestamp()}"),
306
+ "content": message_data.get("content", ""),
307
+ "sender_id": user_id,
308
+ "sender_name": message_data.get("sender_name", ""),
309
+ "is_staff_reply": message_data.get("is_staff_reply", False),
310
+ "created_at": dt.datetime.now(dt.UTC).timestamp()
311
+ }
312
+
313
+ thread.add_message(message)
314
+
315
+ # Update metadata
316
+ thread.updated_by_id = user_id
317
+ thread.prep_for_save()
318
+
319
+ # Save the updated thread
320
+ return self._save_model(thread)
321
+
322
+ except Exception as e:
323
+ return self._handle_service_exception(e, 'add_message_to_contact_thread',
324
+ thread_id=thread_id, tenant_id=tenant_id)
325
+
326
+ def assign_thread(self, thread_id: str, tenant_id: str, user_id: str,
327
+ assigned_to: str, user_inboxes: List[str] = None) -> ServiceResult[ContactThread]:
328
+ """
329
+ Assign a contact thread to a staff member.
330
+
331
+ Args:
332
+ thread_id: Thread ID
333
+ tenant_id: Tenant ID
334
+ user_id: User ID performing the assignment
335
+ assigned_to: Staff user ID to assign to
336
+ user_inboxes: List of inbox IDs the user has access to
337
+
338
+ Returns:
339
+ ServiceResult with updated ContactThread
340
+ """
341
+ try:
342
+ thread_result = self.get_by_id(thread_id, tenant_id, user_id, user_inboxes)
343
+ if not thread_result.success:
344
+ return thread_result
345
+
346
+ thread = thread_result.data
347
+ thread.assign(assigned_to)
348
+ thread.updated_by_id = user_id
349
+ thread.prep_for_save()
350
+
351
+ return self._save_model(thread)
352
+
353
+ except Exception as e:
354
+ return self._handle_service_exception(e, 'assign_contact_thread',
355
+ thread_id=thread_id, assigned_to=assigned_to)
356
+
357
+ def update_status(self, thread_id: str, tenant_id: str, user_id: str,
358
+ status: str, user_inboxes: List[str] = None) -> ServiceResult[ContactThread]:
359
+ """
360
+ Update the status of a contact thread.
361
+
362
+ Args:
363
+ thread_id: Thread ID
364
+ tenant_id: Tenant ID
365
+ user_id: User ID performing the update
366
+ status: New status (open, in_progress, resolved, closed)
367
+ user_inboxes: List of inbox IDs the user has access to
368
+
369
+ Returns:
370
+ ServiceResult with updated ContactThread
371
+ """
372
+ try:
373
+ thread_result = self.get_by_id(thread_id, tenant_id, user_id, user_inboxes)
374
+ if not thread_result.success:
375
+ return thread_result
376
+
377
+ thread = thread_result.data
378
+ thread.status = status
379
+ thread.updated_by_id = user_id
380
+ thread.prep_for_save()
381
+
382
+ return self._save_model(thread)
383
+
384
+ except Exception as e:
385
+ return self._handle_service_exception(e, 'update_contact_thread_status',
386
+ thread_id=thread_id, status=status)
387
+
388
+ def update(self, resource_id: str, tenant_id: str, user_id: str,
389
+ updates: Dict[str, Any], user_inboxes: List[str] = None) -> ServiceResult[ContactThread]:
390
+ """
391
+ Update contact thread with access control.
392
+
393
+ Args:
394
+ resource_id: Thread ID
395
+ tenant_id: Tenant ID
396
+ user_id: User ID performing the update
397
+ updates: Dictionary of fields to update
398
+ user_inboxes: List of inbox IDs the user has access to
399
+
400
+ Returns:
401
+ ServiceResult with updated ContactThread
402
+ """
403
+ try:
404
+ # Get existing thread
405
+ thread = self._get_model_by_id(resource_id, ContactThread)
406
+
407
+ if not thread:
408
+ raise NotFoundError(f"Contact thread with ID {resource_id} not found")
409
+
410
+ # Validate tenant access
411
+ if hasattr(thread, 'tenant_id'):
412
+ self._validate_tenant_access(thread.tenant_id, tenant_id)
413
+
414
+ # Check permissions
415
+ if not thread.can_user_access(user_id, user_inboxes or []):
416
+ raise AccessDeniedError("Access denied: insufficient permissions")
417
+
418
+ # Apply updates (limited fields)
419
+ allowed_fields = ['subject', 'status', 'priority', 'assigned_to', 'tags', 'inbox_id']
420
+ for field, value in updates.items():
421
+ if field in allowed_fields and hasattr(thread, field):
422
+ setattr(thread, field, value)
423
+
424
+ # Update metadata
425
+ thread.updated_by_id = user_id
426
+ thread.prep_for_save()
427
+
428
+ # Save updated thread
429
+ return self._save_model(thread)
430
+
431
+ except Exception as e:
432
+ return self._handle_service_exception(e, 'update_contact_thread', resource_id=resource_id, tenant_id=tenant_id)
433
+
434
+ def delete(self, resource_id: str, tenant_id: str, user_id: str,
435
+ user_inboxes: List[str] = None) -> ServiceResult[bool]:
436
+ """
437
+ Soft delete contact thread with access control.
438
+
439
+ Args:
440
+ resource_id: Thread ID
441
+ tenant_id: Tenant ID
442
+ user_id: User ID performing the deletion
443
+ user_inboxes: List of inbox IDs the user has access to
444
+
445
+ Returns:
446
+ ServiceResult with boolean success
447
+ """
448
+ try:
449
+ # Get existing thread
450
+ thread = self._get_model_by_id(resource_id, ContactThread)
451
+
452
+ if not thread:
453
+ raise NotFoundError(f"Contact thread with ID {resource_id} not found")
454
+
455
+ # Check if already deleted
456
+ if thread.is_deleted():
457
+ return ServiceResult.success_result(True)
458
+
459
+ # Validate tenant access
460
+ if hasattr(thread, 'tenant_id'):
461
+ self._validate_tenant_access(thread.tenant_id, tenant_id)
462
+
463
+ # Check permissions
464
+ if not thread.can_user_access(user_id, user_inboxes or []):
465
+ raise AccessDeniedError("Access denied: insufficient permissions")
466
+
467
+ # Soft delete: set deleted timestamp and metadata
468
+ thread.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
469
+ thread.deleted_by_id = user_id
470
+ thread.prep_for_save()
471
+
472
+ # Save the updated thread
473
+ save_result = self._save_model(thread)
474
+ if save_result.success:
475
+ return ServiceResult.success_result(True)
476
+ else:
477
+ return save_result
478
+
479
+ except Exception as e:
480
+ return self._handle_service_exception(e, 'delete_contact_thread', resource_id=resource_id, tenant_id=tenant_id)
481
+
482
+ def _handle_service_exception(self, exception: Exception, operation: str, **context) -> ServiceResult:
483
+ """
484
+ Handle service exceptions with consistent error responses.
485
+
486
+ Delegates to parent class for proper error code mapping.
487
+
488
+ Args:
489
+ exception: The exception that occurred
490
+ operation: Name of the operation that failed
491
+ **context: Additional context for debugging
492
+
493
+ Returns:
494
+ ServiceResult with error details
495
+ """
496
+ # Use parent's exception handler for proper error code mapping
497
+ return super()._handle_service_exception(exception, operation, **context)
File without changes
@@ -0,0 +1,52 @@
1
+ # src/geek_cafe_saas_sdk/lambda_handlers/subscriptions/activate/app.py
2
+
3
+ import json
4
+ from typing import Dict, Any
5
+
6
+ from geek_cafe_saas_sdk.services import SubscriptionService
7
+ from geek_cafe_saas_sdk.lambda_handlers import ServicePool
8
+ from geek_cafe_saas_sdk.utilities.response import service_result_to_response, error_response
9
+ from geek_cafe_saas_sdk.utilities.lambda_event_utility import LambdaEventUtility
10
+
11
+ subscription_service_pool = ServicePool(SubscriptionService)
12
+
13
+ def handler(event: Dict[str, Any], context: object, injected_service=None) -> Dict[str, Any]:
14
+ """
15
+ Lambda handler for activating a subscription (upgrade/downgrade).
16
+
17
+ This creates a new subscription and sets it as the tenant's active subscription.
18
+ Updates the tenant's plan_tier automatically.
19
+
20
+ Expected body:
21
+ {
22
+ "plan_code": "pro_monthly",
23
+ "plan_name": "Pro Plan",
24
+ "price_cents": 2999,
25
+ "seat_count": 10,
26
+ "current_period_start_utc_ts": 1729123200.0,
27
+ "current_period_end_utc_ts": 1731801600.0
28
+ }
29
+
30
+ Args:
31
+ event: API Gateway event
32
+ context: Lambda context
33
+ injected_service: Optional SubscriptionService for testing
34
+ """
35
+ try:
36
+ subscription_service = injected_service if injected_service else subscription_service_pool.get()
37
+ body = LambdaEventUtility.get_body_from_event(event)
38
+ user_id = LambdaEventUtility.get_authenticated_user_id(event)
39
+ tenant_id = LambdaEventUtility.get_authenticated_user_tenant_id(event)
40
+
41
+ result = subscription_service.activate_subscription(
42
+ tenant_id=tenant_id,
43
+ user_id=user_id,
44
+ payload=body
45
+ )
46
+
47
+ return service_result_to_response(result, success_status=201)
48
+
49
+ except json.JSONDecodeError:
50
+ return error_response("Invalid JSON in request body.", "VALIDATION_ERROR", 400)
51
+ except Exception as e:
52
+ return error_response(f"An unexpected error occurred: {str(e)}", "INTERNAL_ERROR", 500)
@@ -0,0 +1,37 @@
1
+ # src/geek_cafe_saas_sdk/lambda_handlers/subscriptions/active/app.py
2
+
3
+ from typing import Dict, Any
4
+
5
+ from geek_cafe_saas_sdk.services import SubscriptionService
6
+ from geek_cafe_saas_sdk.lambda_handlers import ServicePool
7
+ from geek_cafe_saas_sdk.utilities.response import service_result_to_response, error_response
8
+ from geek_cafe_saas_sdk.utilities.lambda_event_utility import LambdaEventUtility
9
+
10
+ subscription_service_pool = ServicePool(SubscriptionService)
11
+
12
+ def handler(event: Dict[str, Any], context: object, injected_service=None) -> Dict[str, Any]:
13
+ """
14
+ Lambda handler for retrieving the tenant's active subscription.
15
+
16
+ This is a convenience endpoint for /subscriptions/active to get the current subscription.
17
+ Uses the active subscription pointer for O(1) lookup.
18
+
19
+ Args:
20
+ event: API Gateway event
21
+ context: Lambda context
22
+ injected_service: Optional SubscriptionService for testing
23
+ """
24
+ try:
25
+ subscription_service = injected_service if injected_service else subscription_service_pool.get()
26
+ user_id = LambdaEventUtility.get_authenticated_user_id(event)
27
+ tenant_id = LambdaEventUtility.get_authenticated_user_tenant_id(event)
28
+
29
+ result = subscription_service.get_active_subscription(
30
+ tenant_id=tenant_id,
31
+ user_id=user_id
32
+ )
33
+
34
+ return service_result_to_response(result)
35
+
36
+ except Exception as e:
37
+ return error_response(f"An unexpected error occurred: {str(e)}", "INTERNAL_ERROR", 500)
@@ -0,0 +1,55 @@
1
+ # src/geek_cafe_saas_sdk/lambda_handlers/subscriptions/cancel/app.py
2
+
3
+ import json
4
+ from typing import Dict, Any
5
+
6
+ from geek_cafe_saas_sdk.services import SubscriptionService
7
+ from geek_cafe_saas_sdk.lambda_handlers import ServicePool
8
+ from geek_cafe_saas_sdk.utilities.response import service_result_to_response, error_response
9
+ from geek_cafe_saas_sdk.utilities.lambda_event_utility import LambdaEventUtility
10
+
11
+ subscription_service_pool = ServicePool(SubscriptionService)
12
+
13
+ def handler(event: Dict[str, Any], context: object, injected_service=None) -> Dict[str, Any]:
14
+ """
15
+ Lambda handler for canceling a subscription.
16
+
17
+ Expected body (optional):
18
+ {
19
+ "reason": "User requested cancellation",
20
+ "immediate": false // If true, cancel immediately. If false, cancel at period end.
21
+ }
22
+
23
+ Args:
24
+ event: API Gateway event
25
+ context: Lambda context
26
+ injected_service: Optional SubscriptionService for testing
27
+ """
28
+ try:
29
+ subscription_service = injected_service if injected_service else subscription_service_pool.get()
30
+ body = LambdaEventUtility.get_body_from_event(event) or {}
31
+ user_id = LambdaEventUtility.get_authenticated_user_id(event)
32
+ tenant_id = LambdaEventUtility.get_authenticated_user_tenant_id(event)
33
+ resource_id = LambdaEventUtility.get_value_from_path_parameters(event, 'id')
34
+
35
+ if not resource_id:
36
+ return error_response("Subscription ID is required in the path.", "VALIDATION_ERROR", 400)
37
+
38
+ # Get optional cancellation parameters
39
+ reason = body.get('reason', 'User requested cancellation')
40
+ immediate = body.get('immediate', False)
41
+
42
+ result = subscription_service.cancel_subscription(
43
+ subscription_id=resource_id,
44
+ tenant_id=tenant_id,
45
+ user_id=user_id,
46
+ reason=reason,
47
+ immediate=immediate
48
+ )
49
+
50
+ return service_result_to_response(result)
51
+
52
+ except json.JSONDecodeError:
53
+ return error_response("Invalid JSON in request body.", "VALIDATION_ERROR", 400)
54
+ except Exception as e:
55
+ return error_response(f"An unexpected error occurred: {str(e)}", "INTERNAL_ERROR", 500)
@@ -0,0 +1,39 @@
1
+ # src/geek_cafe_saas_sdk/lambda_handlers/subscriptions/get/app.py
2
+
3
+ from typing import Dict, Any
4
+
5
+ from geek_cafe_saas_sdk.services import SubscriptionService
6
+ from geek_cafe_saas_sdk.lambda_handlers import ServicePool
7
+ from geek_cafe_saas_sdk.utilities.response import service_result_to_response, error_response
8
+ from geek_cafe_saas_sdk.utilities.lambda_event_utility import LambdaEventUtility
9
+
10
+ subscription_service_pool = ServicePool(SubscriptionService)
11
+
12
+ def handler(event: Dict[str, Any], context: object, injected_service=None) -> Dict[str, Any]:
13
+ """
14
+ Lambda handler for retrieving a subscription by ID.
15
+
16
+ Args:
17
+ event: API Gateway event
18
+ context: Lambda context
19
+ injected_service: Optional SubscriptionService for testing
20
+ """
21
+ try:
22
+ subscription_service = injected_service if injected_service else subscription_service_pool.get()
23
+ user_id = LambdaEventUtility.get_authenticated_user_id(event)
24
+ tenant_id = LambdaEventUtility.get_authenticated_user_tenant_id(event)
25
+ resource_id = LambdaEventUtility.get_value_from_path_parameters(event, 'id')
26
+
27
+ if not resource_id:
28
+ return error_response("Subscription ID is required in the path.", "VALIDATION_ERROR", 400)
29
+
30
+ result = subscription_service.get_by_id(
31
+ resource_id=resource_id,
32
+ tenant_id=tenant_id,
33
+ user_id=user_id
34
+ )
35
+
36
+ return service_result_to_response(result)
37
+
38
+ except Exception as e:
39
+ return error_response(f"An unexpected error occurred: {str(e)}", "INTERNAL_ERROR", 500)