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,392 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ ContactThread model for guest-initiated contact and support tickets.
6
+ """
7
+
8
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
9
+ from boto3_assist.utilities.string_utility import StringUtility
10
+ import datetime as dt
11
+ from typing import List, Optional, Dict, Any
12
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
13
+
14
+
15
+ class ContactThread(BaseModel):
16
+ """
17
+ ContactThread model for contact forms, support tickets, and guest communications.
18
+
19
+ Optimized for low-volume conversations with asymmetric access (guest sender vs staff responders).
20
+ Supports status workflow, assignment, and notification tracking.
21
+
22
+ Features:
23
+ - Guest-initiated contact (no auth required to create)
24
+ - Staff response and assignment tracking
25
+ - Status workflow (open, in_progress, resolved, closed)
26
+ - Priority levels
27
+ - Email notification support
28
+ - Inbox-based routing (support, sales, billing, etc.)
29
+ - Embedded messages (suitable for ~100 messages max)
30
+ """
31
+
32
+ def __init__(self):
33
+ super().__init__()
34
+ self._subject: str | None = None
35
+ self._status: str = "open" # open, in_progress, resolved, closed
36
+ self._priority: str = "medium" # low, medium, high, urgent
37
+
38
+ # Sender information (guest or authenticated user)
39
+ self._sender: Dict[str, Any] = {} # {id, name, email, session_id}
40
+
41
+ # Assignment and routing
42
+ self._assigned_to: str | None = None # Staff user ID
43
+ self._inbox_id: str = "support" # support, sales, billing, etc.
44
+
45
+ # Messages embedded in thread (suitable for low volume)
46
+ self._messages: List[Dict[str, Any]] = []
47
+
48
+ # Timestamps for workflow tracking
49
+ self._first_response_at: float | None = None
50
+ self._resolved_at: float | None = None
51
+ self._last_message_at: float | None = None
52
+
53
+ # Notification tracking
54
+ self._guest_notified: bool = False
55
+ self._notification_email: str | None = None
56
+
57
+ # Metadata and tagging
58
+ self._tags: List[str] = []
59
+ self._source: str = "web" # web, mobile, api, email
60
+ self._is_archived: bool = False
61
+
62
+ self._setup_indexes()
63
+
64
+ def _setup_indexes(self):
65
+ """Setup DynamoDB indexes for efficient querying."""
66
+
67
+ # Primary index: threads by ID
68
+ primary: DynamoDBIndex = DynamoDBIndex()
69
+ primary.name = "primary"
70
+ primary.partition_key.attribute_name = "pk"
71
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("contact", self.id))
72
+ primary.sort_key.attribute_name = "sk"
73
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("contact", self.id))
74
+ self.indexes.add_primary(primary)
75
+
76
+ # GSI1: Query by inbox and status (most common query)
77
+ # Allows: "Show me all open tickets in support inbox"
78
+ gsi: DynamoDBIndex = DynamoDBIndex()
79
+ gsi.name = "gsi1"
80
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
81
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
82
+ ("inbox", self.inbox_id),
83
+ ("status", self.status)
84
+ )
85
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
86
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
87
+ ("priority", self.priority),
88
+ ("ts", self.created_utc_ts)
89
+ )
90
+ self.indexes.add_secondary(gsi)
91
+
92
+ # GSI2: Query by tenant and status
93
+ # Allows: "Show me all open tickets for this tenant"
94
+ gsi: DynamoDBIndex = DynamoDBIndex()
95
+ gsi.name = "gsi2"
96
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
97
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
98
+ ("tenant", self.tenant_id),
99
+ ("status", self.status)
100
+ )
101
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
102
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
103
+ ("ts", self.last_message_at or self.created_utc_ts)
104
+ )
105
+ self.indexes.add_secondary(gsi)
106
+
107
+ # GSI3: Query by assigned staff member
108
+ # Allows: "Show me all tickets assigned to me"
109
+ gsi: DynamoDBIndex = DynamoDBIndex()
110
+ gsi.name = "gsi3"
111
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
112
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
113
+ ("assigned", self.assigned_to)
114
+ )
115
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
116
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
117
+ ("status", self.status),
118
+ ("ts", self.last_message_at or self.created_utc_ts)
119
+ )
120
+ self.indexes.add_secondary(gsi)
121
+
122
+ # GSI4: All threads by tenant (for admin views)
123
+ gsi: DynamoDBIndex = DynamoDBIndex()
124
+ gsi.name = "gsi4"
125
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
126
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
127
+ ("tenant", self.tenant_id)
128
+ )
129
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
130
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
131
+ ("model", "contact"),
132
+ ("ts", self.created_utc_ts)
133
+ )
134
+ self.indexes.add_secondary(gsi)
135
+
136
+ # GSI5: Query by sender email (find all contacts from same guest)
137
+ gsi: DynamoDBIndex = DynamoDBIndex()
138
+ gsi.name = "gsi5"
139
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
140
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
141
+ ("sender", self.sender.get("email") if self.sender else None)
142
+ )
143
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
144
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
145
+ ("ts", self.created_utc_ts)
146
+ )
147
+ self.indexes.add_secondary(gsi)
148
+
149
+ # Subject
150
+ @property
151
+ def subject(self) -> str | None:
152
+ return self._subject
153
+
154
+ @subject.setter
155
+ def subject(self, value: str | None):
156
+ self._subject = value
157
+
158
+ # Status
159
+ @property
160
+ def status(self) -> str:
161
+ return self._status
162
+
163
+ @status.setter
164
+ def status(self, value: str | None):
165
+ valid_statuses = ["open", "in_progress", "resolved", "closed"]
166
+ if value in valid_statuses:
167
+ self._status = value
168
+ # Auto-set resolved_at when status changes to resolved
169
+ if value == "resolved" and not self._resolved_at:
170
+ self._resolved_at = dt.datetime.now(dt.UTC).timestamp()
171
+ else:
172
+ self._status = "open" # default
173
+
174
+ # Priority
175
+ @property
176
+ def priority(self) -> str:
177
+ return self._priority
178
+
179
+ @priority.setter
180
+ def priority(self, value: str | None):
181
+ valid_priorities = ["low", "medium", "high", "urgent"]
182
+ if value in valid_priorities:
183
+ self._priority = value
184
+ else:
185
+ self._priority = "medium" # default
186
+
187
+ # Sender
188
+ @property
189
+ def sender(self) -> Dict[str, Any]:
190
+ return self._sender
191
+
192
+ @sender.setter
193
+ def sender(self, value: Dict[str, Any] | None):
194
+ self._sender = value if value else {}
195
+
196
+ # Assigned To
197
+ @property
198
+ def assigned_to(self) -> str | None:
199
+ return self._assigned_to
200
+
201
+ @assigned_to.setter
202
+ def assigned_to(self, value: str | None):
203
+ self._assigned_to = value
204
+
205
+ # Inbox ID
206
+ @property
207
+ def inbox_id(self) -> str:
208
+ return self._inbox_id
209
+
210
+ @inbox_id.setter
211
+ def inbox_id(self, value: str | None):
212
+ self._inbox_id = value if value else "support"
213
+
214
+ # Messages
215
+ @property
216
+ def messages(self) -> List[Dict[str, Any]]:
217
+ return self._messages
218
+
219
+ @messages.setter
220
+ def messages(self, value: List[Dict[str, Any]] | None):
221
+ if value is None:
222
+ self._messages = []
223
+ elif isinstance(value, list):
224
+ self._messages = value
225
+ else:
226
+ self._messages = []
227
+
228
+ # First Response At
229
+ @property
230
+ def first_response_at(self) -> float | None:
231
+ return self._first_response_at
232
+
233
+ @first_response_at.setter
234
+ def first_response_at(self, value: float | None):
235
+ self._first_response_at = value
236
+
237
+ # Resolved At
238
+ @property
239
+ def resolved_at(self) -> float | None:
240
+ return self._resolved_at
241
+
242
+ @resolved_at.setter
243
+ def resolved_at(self, value: float | None):
244
+ self._resolved_at = value
245
+
246
+ # Last Message At
247
+ @property
248
+ def last_message_at(self) -> float | None:
249
+ return self._last_message_at
250
+
251
+ @last_message_at.setter
252
+ def last_message_at(self, value: float | None):
253
+ self._last_message_at = value
254
+
255
+ # Guest Notified
256
+ @property
257
+ def guest_notified(self) -> bool:
258
+ return self._guest_notified
259
+
260
+ @guest_notified.setter
261
+ def guest_notified(self, value: bool | None):
262
+ self._guest_notified = value if value is not None else False
263
+
264
+ # Notification Email
265
+ @property
266
+ def notification_email(self) -> str | None:
267
+ return self._notification_email
268
+
269
+ @notification_email.setter
270
+ def notification_email(self, value: str | None):
271
+ self._notification_email = value
272
+
273
+ # Tags
274
+ @property
275
+ def tags(self) -> List[str]:
276
+ return self._tags
277
+
278
+ @tags.setter
279
+ def tags(self, value: List[str] | None):
280
+ if value is None:
281
+ self._tags = []
282
+ elif isinstance(value, list):
283
+ self._tags = value
284
+ else:
285
+ self._tags = []
286
+
287
+ # Source
288
+ @property
289
+ def source(self) -> str:
290
+ return self._source
291
+
292
+ @source.setter
293
+ def source(self, value: str | None):
294
+ self._source = value if value else "web"
295
+
296
+ # Is Archived
297
+ @property
298
+ def is_archived(self) -> bool:
299
+ return self._is_archived
300
+
301
+ @is_archived.setter
302
+ def is_archived(self, value: bool | None):
303
+ self._is_archived = value if value is not None else False
304
+
305
+ # Helper Methods
306
+
307
+ def add_message(self, message: Dict[str, Any]):
308
+ """
309
+ Add a message to the contact thread.
310
+
311
+ Args:
312
+ message: Message dict with fields: content, sender_id, sender_name, is_staff_reply, etc.
313
+ """
314
+ if message not in self._messages:
315
+ # Ensure message has timestamp
316
+ if "created_at" not in message:
317
+ message["created_at"] = dt.datetime.now(dt.UTC).timestamp()
318
+
319
+ self._messages.append(message)
320
+ self._last_message_at = message["created_at"]
321
+
322
+ # Track first staff response
323
+ if message.get("is_staff_reply") and not self._first_response_at:
324
+ self._first_response_at = message["created_at"]
325
+
326
+ def get_message_count(self) -> int:
327
+ """Get total number of messages in thread."""
328
+ return len(self._messages)
329
+
330
+ def get_recent_messages(self, limit: int = 5) -> List[Dict[str, Any]]:
331
+ """
332
+ Get recent messages from the thread.
333
+
334
+ Args:
335
+ limit: Number of messages to return
336
+
337
+ Returns:
338
+ List of most recent messages, newest first
339
+ """
340
+ sorted_messages = sorted(
341
+ self._messages,
342
+ key=lambda m: m.get("created_at", 0),
343
+ reverse=True
344
+ )
345
+ return sorted_messages[:limit]
346
+
347
+ def assign(self, staff_user_id: str):
348
+ """
349
+ Assign this thread to a staff member.
350
+
351
+ Args:
352
+ staff_user_id: ID of the staff member to assign to
353
+ """
354
+ self.assigned_to = staff_user_id
355
+ # Auto-change status to in_progress if currently open
356
+ if self.status == "open":
357
+ self.status = "in_progress"
358
+
359
+ def resolve(self):
360
+ """Mark this thread as resolved."""
361
+ self.status = "resolved"
362
+ self.resolved_at = dt.datetime.now(dt.UTC).timestamp()
363
+
364
+ def reopen(self):
365
+ """Reopen a resolved or closed thread."""
366
+ self.status = "open"
367
+ self.resolved_at = None
368
+
369
+ def can_user_access(self, user_id: str, user_inboxes: List[str] = None) -> bool:
370
+ """
371
+ Check if a user can access this contact thread.
372
+
373
+ Args:
374
+ user_id: User ID to check
375
+ user_inboxes: List of inbox IDs the user has access to (e.g., ["support-inbox", "sales-inbox"])
376
+
377
+ Returns:
378
+ True if user can access, False otherwise
379
+ """
380
+ # Check if user is the sender
381
+ if self.sender and self.sender.get("id") == user_id:
382
+ return True
383
+
384
+ # Check if user is assigned to this thread
385
+ if self.assigned_to == user_id:
386
+ return True
387
+
388
+ # Check if user has access to the inbox this thread belongs to
389
+ if user_inboxes and self.inbox_id in user_inboxes:
390
+ return True
391
+
392
+ return False
@@ -0,0 +1,11 @@
1
+ # Messaging Domain Services
2
+
3
+ from .chat_channel_service import ChatChannelService
4
+ from .chat_message_service import ChatMessageService
5
+ from .contact_thread_service import ContactThreadService
6
+
7
+ __all__ = [
8
+ "ChatChannelService",
9
+ "ChatMessageService",
10
+ "ContactThreadService",
11
+ ]