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,227 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ CommunityMember model for scalable community membership using single-table design.
6
+ """
7
+
8
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
9
+ import datetime as dt
10
+ from typing import Optional
11
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
12
+
13
+
14
+ class CommunityMember(BaseModel):
15
+ """
16
+ CommunityMember model for scalable membership tracking.
17
+
18
+ Single-table design pattern:
19
+ - PK: community#<community_id>
20
+ - SK: member#<user_id>
21
+ - GSI1PK: user#<user_id> (for "what communities am I in?")
22
+ - GSI1SK: community#<community_id>#<joined_at>
23
+
24
+ Benefits:
25
+ - Unlimited members per community
26
+ - Fast membership checks (single GetItem)
27
+ - Member metadata (role, status, join date)
28
+ - Efficient pagination
29
+ - Concurrent updates without contention
30
+ """
31
+
32
+ def __init__(self):
33
+ super().__init__()
34
+ # Note: Uses normal UUID id, but pk/sk create adjacent record pattern
35
+ self._community_id: str | None = None
36
+ self._user_id: str | None = None
37
+
38
+ # Member metadata
39
+ self._status: str = "active" # active, pending, banned, left
40
+ self._joined_at: float | None = None
41
+ self._invited_by_id: str | None = None
42
+
43
+ # Dues tracking
44
+ self._dues_status: str = "current" # current, overdue, exempt, pending
45
+ self._last_payment_at: float | None = None
46
+ self._next_payment_due: float | None = None
47
+
48
+ # Activity tracking
49
+ self._last_active_at: float | None = None
50
+ self._event_attendance_count: int = 0
51
+
52
+ # Setup DynamoDB indexes (adjacent record pattern)
53
+ self._setup_indexes()
54
+
55
+ def _setup_indexes(self):
56
+ """Setup DynamoDB indexes for member queries."""
57
+
58
+ # Primary index: Adjacent record pattern - members grouped by community
59
+ # Query: pk="community#<id>" AND sk BEGINS_WITH "member#" to get all members
60
+ primary: DynamoDBIndex = DynamoDBIndex()
61
+ primary.name = "primary"
62
+ primary.partition_key.attribute_name = "pk"
63
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id))
64
+ primary.sort_key.attribute_name = "sk"
65
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("member", self.user_id))
66
+ self.indexes.add_primary(primary)
67
+
68
+ # GSI1: User's communities - query communities by user
69
+ gsi: DynamoDBIndex = DynamoDBIndex()
70
+ gsi.name = "gsi1"
71
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
72
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("user", self.user_id))
73
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
74
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("ts", self.joined_at))
75
+ self.indexes.add_secondary(gsi)
76
+
77
+ # GSI2: Members by status - query members by status within community
78
+ gsi: DynamoDBIndex = DynamoDBIndex()
79
+ gsi.name = "gsi2"
80
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
81
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("status", self.status))
82
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
83
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("member", self.user_id))
84
+ self.indexes.add_secondary(gsi)
85
+
86
+ # GSI3: Members by dues status - query members with overdue dues
87
+ gsi: DynamoDBIndex = DynamoDBIndex()
88
+ gsi.name = "gsi3"
89
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
90
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("dues", self.dues_status))
91
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
92
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("due_date", self.next_payment_due or 0))
93
+ self.indexes.add_secondary(gsi)
94
+
95
+ @property
96
+ def model_version(self) -> str:
97
+ return "1.0"
98
+
99
+ def prep_for_save(self):
100
+ """
101
+ Prepare member record for save.
102
+
103
+ Note: CommunityMember uses adjacent record pattern where:
104
+ - Normal UUID id (generated by parent)
105
+ - pk = "community#<community_id>" (groups by community)
106
+ - sk = "member#<user_id>" (enables begins_with queries)
107
+ """
108
+ # Validate required fields for adjacent record pattern
109
+ if not self.community_id:
110
+ raise ValueError("community_id is required for CommunityMember")
111
+ if not self.user_id:
112
+ raise ValueError("user_id is required for CommunityMember")
113
+
114
+ # Set joined_at if not already set
115
+ if not self.joined_at:
116
+ self.joined_at = dt.datetime.now(dt.UTC).timestamp()
117
+
118
+ # Call parent to generate normal UUID id and set timestamps
119
+ super().prep_for_save()
120
+
121
+ # Properties
122
+ @property
123
+ def community_id(self) -> str:
124
+ return self._community_id
125
+
126
+ @community_id.setter
127
+ def community_id(self, value: str | None):
128
+ self._community_id = value
129
+
130
+ @property
131
+ def user_id(self) -> str:
132
+ return self._user_id
133
+
134
+ @user_id.setter
135
+ def user_id(self, value: str | None):
136
+ self._user_id = value
137
+
138
+ @property
139
+ def status(self) -> str:
140
+ return self._status
141
+
142
+ @status.setter
143
+ def status(self, value: str | None):
144
+ valid_statuses = ["active", "pending", "banned", "left"]
145
+ self._status = value if value in valid_statuses else "active"
146
+
147
+ @property
148
+ def joined_at(self) -> float:
149
+ return self._joined_at
150
+
151
+ @joined_at.setter
152
+ def joined_at(self, value: float | None):
153
+ self._joined_at = value
154
+
155
+ @property
156
+ def invited_by_id(self) -> str:
157
+ return self._invited_by_id
158
+
159
+ @invited_by_id.setter
160
+ def invited_by_id(self, value: str | None):
161
+ self._invited_by_id = value
162
+
163
+ @property
164
+ def dues_status(self) -> str:
165
+ return self._dues_status
166
+
167
+ @dues_status.setter
168
+ def dues_status(self, value: str | None):
169
+ valid_statuses = ["current", "overdue", "exempt", "pending"]
170
+ self._dues_status = value if value in valid_statuses else "current"
171
+
172
+ @property
173
+ def last_payment_at(self) -> float:
174
+ return self._last_payment_at
175
+
176
+ @last_payment_at.setter
177
+ def last_payment_at(self, value: float | None):
178
+ self._last_payment_at = value
179
+
180
+ @property
181
+ def next_payment_due(self) -> float:
182
+ return self._next_payment_due
183
+
184
+ @next_payment_due.setter
185
+ def next_payment_due(self, value: float | None):
186
+ self._next_payment_due = value
187
+
188
+ @property
189
+ def last_active_at(self) -> float:
190
+ return self._last_active_at
191
+
192
+ @last_active_at.setter
193
+ def last_active_at(self, value: float | None):
194
+ self._last_active_at = value
195
+
196
+ @property
197
+ def event_attendance_count(self) -> int:
198
+ return self._event_attendance_count
199
+
200
+ @event_attendance_count.setter
201
+ def event_attendance_count(self, value: int | None):
202
+ self._event_attendance_count = value if isinstance(value, int) and value >= 0 else 0
203
+
204
+ # Helper methods
205
+ def is_active(self) -> bool:
206
+ """Check if member is active."""
207
+ return self.status == "active"
208
+
209
+ def is_pending(self) -> bool:
210
+ """Check if membership is pending approval."""
211
+ return self.status == "pending"
212
+
213
+ def is_banned(self) -> bool:
214
+ """Check if member is banned."""
215
+ return self.status == "banned"
216
+
217
+ def has_left(self) -> bool:
218
+ """Check if member has left the community."""
219
+ return self.status == "left"
220
+
221
+ def is_dues_current(self) -> bool:
222
+ """Check if member's dues are current."""
223
+ return self.dues_status == "current" or self.dues_status == "exempt"
224
+
225
+ def is_dues_overdue(self) -> bool:
226
+ """Check if member's dues are overdue."""
227
+ return self.dues_status == "overdue"
@@ -0,0 +1,6 @@
1
+ # Communities Domain Services
2
+
3
+ from .community_service import CommunityService
4
+ from .community_member_service import CommunityMemberService
5
+
6
+ __all__ = ["CommunityService", "CommunityMemberService"]
@@ -0,0 +1,412 @@
1
+ # Community Member Service
2
+
3
+ from typing import Dict, Any, List, Optional
4
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
5
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
6
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
7
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
8
+ from geek_cafe_saas_sdk.domains.communities.models import CommunityMember
9
+ import datetime as dt
10
+
11
+
12
+ class CommunityMemberService(DatabaseService[CommunityMember]):
13
+ """
14
+ Service for CommunityMember operations.
15
+
16
+ Manages scalable membership using adjacent record pattern.
17
+ """
18
+
19
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
20
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
21
+
22
+ # Required abstract methods from DatabaseService
23
+ def create(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[CommunityMember]:
24
+ """Create method - delegates to add_member()."""
25
+ if 'community_id' not in kwargs:
26
+ return ServiceResult.error_result("community_id is required", "VALIDATION_ERROR")
27
+
28
+ return self.add_member(
29
+ community_id=kwargs['community_id'],
30
+ user_id=user_id,
31
+ invited_by_id=kwargs.get('invited_by_id'),
32
+ status=kwargs.get('status', 'active')
33
+ )
34
+
35
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[CommunityMember]:
36
+ """Get method - resource_id should be in format 'community_id:user_id'."""
37
+ try:
38
+ if ':' in resource_id:
39
+ community_id, member_user_id = resource_id.split(':', 1)
40
+ return self.get_membership(community_id, member_user_id)
41
+ return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
42
+ except Exception as e:
43
+ return self._handle_service_exception(e, 'get_by_id', resource_id=resource_id)
44
+
45
+ def update(self, resource_id: str, tenant_id: str, user_id: str, updates: Dict[str, Any]) -> ServiceResult[CommunityMember]:
46
+ """Update method - updates member record."""
47
+ try:
48
+ if ':' in resource_id:
49
+ community_id, member_user_id = resource_id.split(':', 1)
50
+ membership = self.get_membership(community_id, member_user_id)
51
+
52
+ if not membership.success or not membership.data:
53
+ return ServiceResult.error_result("Membership not found")
54
+
55
+ # Apply updates
56
+ member = membership.data
57
+ if 'status' in updates:
58
+ member.status = updates['status']
59
+ if 'dues_status' in updates:
60
+ member.dues_status = updates['dues_status']
61
+ if 'last_payment_at' in updates:
62
+ member.last_payment_at = updates['last_payment_at']
63
+ if 'next_payment_due' in updates:
64
+ member.next_payment_due = updates['next_payment_due']
65
+
66
+ member.prep_for_save()
67
+ return self._save_model(member)
68
+
69
+ return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
70
+ except Exception as e:
71
+ return self._handle_service_exception(e, 'update', resource_id=resource_id)
72
+
73
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
74
+ """Delete method - soft deletes member."""
75
+ try:
76
+ if ':' in resource_id:
77
+ community_id, member_user_id = resource_id.split(':', 1)
78
+ return self.remove_member(community_id, member_user_id)
79
+ return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
80
+ except Exception as e:
81
+ return self._handle_service_exception(e, 'delete', resource_id=resource_id)
82
+
83
+ def add_member(self, community_id: str, user_id: str, invited_by_id: str = None,
84
+ status: str = "active") -> ServiceResult[CommunityMember]:
85
+ """
86
+ Add a member to a community.
87
+
88
+ Args:
89
+ community_id: Community ID
90
+ user_id: User ID to add
91
+ invited_by_id: Optional ID of user who invited
92
+ status: Member status (active, pending, etc.)
93
+ """
94
+ try:
95
+ # Check if already a member
96
+ existing = self.get_membership(community_id, user_id)
97
+ if existing.success and existing.data:
98
+ if existing.data.status == "active":
99
+ return ServiceResult.success_result(existing.data)
100
+ # Reactivate if previously left or banned
101
+ existing.data.status = status
102
+ existing.data.joined_at = dt.datetime.now(dt.UTC).timestamp()
103
+ existing.data.prep_for_save()
104
+ return self._save_model(existing.data)
105
+
106
+ # Create new membership
107
+ member = CommunityMember()
108
+ member.community_id = community_id
109
+ member.user_id = user_id
110
+ member.status = status
111
+ member.invited_by_id = invited_by_id
112
+ member.joined_at = dt.datetime.now(dt.UTC).timestamp()
113
+ member.tenant_id = community_id # For tenant isolation if needed
114
+ member.prep_for_save()
115
+
116
+ return self._save_model(member)
117
+
118
+ except Exception as e:
119
+ return self._handle_service_exception(
120
+ e, 'add_member',
121
+ community_id=community_id,
122
+ user_id=user_id
123
+ )
124
+
125
+ def remove_member(self, community_id: str, user_id: str) -> ServiceResult[bool]:
126
+ """
127
+ Remove a member from a community (soft delete by status change).
128
+
129
+ Args:
130
+ community_id: Community ID
131
+ user_id: User ID to remove
132
+ """
133
+ try:
134
+ membership = self.get_membership(community_id, user_id)
135
+
136
+ if not membership.success or not membership.data:
137
+ return ServiceResult.success_result(True) # Already not a member
138
+
139
+ # Mark as left instead of hard delete
140
+ membership.data.status = "left"
141
+ membership.data.prep_for_save()
142
+
143
+ return self._save_model(membership.data).map(lambda _: True)
144
+
145
+ except Exception as e:
146
+ return self._handle_service_exception(
147
+ e, 'remove_member',
148
+ community_id=community_id,
149
+ user_id=user_id
150
+ )
151
+
152
+ def get_membership(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
153
+ """
154
+ Get a specific membership record.
155
+
156
+ Args:
157
+ community_id: Community ID
158
+ user_id: User ID
159
+ """
160
+ try:
161
+ # Create temp object to build the primary key
162
+ temp = CommunityMember()
163
+ temp.community_id = community_id
164
+ temp.user_id = user_id
165
+
166
+ # Get by primary key (community + user)
167
+ result = self._get_by_primary_key(temp)
168
+
169
+ if not result.success or not result.data:
170
+ raise NotFoundError("Membership not found")
171
+
172
+ return result
173
+
174
+ except Exception as e:
175
+ return self._handle_service_exception(
176
+ e, 'get_membership',
177
+ community_id=community_id,
178
+ user_id=user_id
179
+ )
180
+
181
+ def is_member(self, community_id: str, user_id: str, active_only: bool = True) -> bool:
182
+ """
183
+ Check if user is a member of community.
184
+
185
+ Args:
186
+ community_id: Community ID
187
+ user_id: User ID
188
+ active_only: Only count active members (default True)
189
+ """
190
+ try:
191
+ result = self.get_membership(community_id, user_id)
192
+
193
+ if not result.success or not result.data:
194
+ return False
195
+
196
+ if active_only:
197
+ return result.data.is_active()
198
+
199
+ return result.data.status in ["active", "pending"]
200
+
201
+ except:
202
+ return False
203
+
204
+ def list_members(self, community_id: str, status: str = None,
205
+ limit: int = 50) -> ServiceResult[List[CommunityMember]]:
206
+ """
207
+ List members of a community.
208
+
209
+ Args:
210
+ community_id: Community ID
211
+ status: Optional status filter (active, pending, etc.)
212
+ limit: Max results
213
+ """
214
+ try:
215
+ temp = CommunityMember()
216
+ temp.community_id = community_id
217
+
218
+ if status:
219
+ # Query by status using GSI2
220
+ temp.status = status
221
+ result = self._query_by_index(temp, "gsi2", limit=limit, ascending=True)
222
+ else:
223
+ # Query all members using primary key begins_with
224
+ result = self._query_by_index(temp, "primary", limit=limit, ascending=True)
225
+
226
+ if not result.success:
227
+ return result
228
+
229
+ # Filter out deleted/left members if no status specified
230
+ if not status:
231
+ active_members = [m for m in result.data if m.status in ["active", "pending"]]
232
+ return ServiceResult.success_result(active_members)
233
+
234
+ return result
235
+
236
+ except Exception as e:
237
+ return self._handle_service_exception(
238
+ e, 'list_members',
239
+ community_id=community_id
240
+ )
241
+
242
+ def list_user_communities(self, user_id: str, status: str = "active",
243
+ limit: int = 50) -> ServiceResult[List[CommunityMember]]:
244
+ """
245
+ List communities a user is a member of.
246
+
247
+ Args:
248
+ user_id: User ID
249
+ status: Member status filter (default: active)
250
+ limit: Max results
251
+ """
252
+ try:
253
+ temp = CommunityMember()
254
+ temp.user_id = user_id
255
+
256
+ # Query by user using GSI1
257
+ result = self._query_by_index(
258
+ temp,
259
+ "gsi1",
260
+ limit=limit,
261
+ ascending=False # Most recently joined first
262
+ )
263
+
264
+ if not result.success:
265
+ return result
266
+
267
+ # Filter by status if specified
268
+ if status:
269
+ filtered = [m for m in result.data if m.status == status]
270
+ return ServiceResult.success_result(filtered)
271
+
272
+ return result
273
+
274
+ except Exception as e:
275
+ return self._handle_service_exception(
276
+ e, 'list_user_communities',
277
+ user_id=user_id
278
+ )
279
+
280
+ def get_member_count(self, community_id: str, status: str = "active") -> ServiceResult[int]:
281
+ """
282
+ Get count of members in a community.
283
+
284
+ Args:
285
+ community_id: Community ID
286
+ status: Status filter (default: active)
287
+ """
288
+ try:
289
+ members_result = self.list_members(community_id, status=status, limit=1000)
290
+
291
+ if not members_result.success:
292
+ return ServiceResult.error_result("Failed to count members")
293
+
294
+ count = len(members_result.data)
295
+ return ServiceResult.success_result(count)
296
+
297
+ except Exception as e:
298
+ return self._handle_service_exception(
299
+ e, 'get_member_count',
300
+ community_id=community_id
301
+ )
302
+
303
+ def update_member_status(self, community_id: str, user_id: str,
304
+ status: str) -> ServiceResult[CommunityMember]:
305
+ """
306
+ Update a member's status.
307
+
308
+ Args:
309
+ community_id: Community ID
310
+ user_id: User ID
311
+ status: New status (active, pending, banned, left)
312
+ """
313
+ try:
314
+ membership = self.get_membership(community_id, user_id)
315
+
316
+ if not membership.success or not membership.data:
317
+ raise NotFoundError("Membership not found")
318
+
319
+ membership.data.status = status
320
+ membership.data.prep_for_save()
321
+
322
+ return self._save_model(membership.data)
323
+
324
+ except Exception as e:
325
+ return self._handle_service_exception(
326
+ e, 'update_member_status',
327
+ community_id=community_id,
328
+ user_id=user_id
329
+ )
330
+
331
+ def approve_pending_member(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
332
+ """
333
+ Approve a pending member.
334
+
335
+ Args:
336
+ community_id: Community ID
337
+ user_id: User ID to approve
338
+ """
339
+ return self.update_member_status(community_id, user_id, "active")
340
+
341
+ def ban_member(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
342
+ """
343
+ Ban a member from the community.
344
+
345
+ Args:
346
+ community_id: Community ID
347
+ user_id: User ID to ban
348
+ """
349
+ return self.update_member_status(community_id, user_id, "banned")
350
+
351
+ def update_dues_status(self, community_id: str, user_id: str,
352
+ dues_status: str, next_payment_due: float = None) -> ServiceResult[CommunityMember]:
353
+ """
354
+ Update member's dues status.
355
+
356
+ Args:
357
+ community_id: Community ID
358
+ user_id: User ID
359
+ dues_status: New dues status (current, overdue, exempt, pending)
360
+ next_payment_due: Optional next payment due date (timestamp)
361
+ """
362
+ try:
363
+ membership = self.get_membership(community_id, user_id)
364
+
365
+ if not membership.success or not membership.data:
366
+ raise NotFoundError("Membership not found")
367
+
368
+ membership.data.dues_status = dues_status
369
+ if dues_status == "current":
370
+ membership.data.last_payment_at = dt.datetime.now(dt.UTC).timestamp()
371
+ if next_payment_due:
372
+ membership.data.next_payment_due = next_payment_due
373
+
374
+ membership.data.prep_for_save()
375
+
376
+ return self._save_model(membership.data)
377
+
378
+ except Exception as e:
379
+ return self._handle_service_exception(
380
+ e, 'update_dues_status',
381
+ community_id=community_id,
382
+ user_id=user_id
383
+ )
384
+
385
+ def list_members_with_overdue_dues(self, community_id: str,
386
+ limit: int = 50) -> ServiceResult[List[CommunityMember]]:
387
+ """
388
+ List members with overdue dues using GSI3.
389
+
390
+ Args:
391
+ community_id: Community ID
392
+ limit: Max results
393
+ """
394
+ try:
395
+ temp = CommunityMember()
396
+ temp.community_id = community_id
397
+ temp.dues_status = "overdue"
398
+
399
+ result = self._query_by_index(
400
+ temp,
401
+ "gsi3",
402
+ limit=limit,
403
+ ascending=True # Oldest overdue first
404
+ )
405
+
406
+ return result
407
+
408
+ except Exception as e:
409
+ return self._handle_service_exception(
410
+ e, 'list_members_with_overdue_dues',
411
+ community_id=community_id
412
+ )