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,663 @@
1
+ """
2
+ FileShareService for permission-based file sharing.
3
+
4
+ Geek Cafe, LLC
5
+ MIT License. See Project Root for the license information.
6
+ """
7
+
8
+ from typing import Dict, Any, Optional, List
9
+ from boto3.dynamodb.conditions import Key
10
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
11
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
12
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
13
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
14
+ from geek_cafe_saas_sdk.core.error_codes import ErrorCode
15
+ from geek_cafe_saas_sdk.domains.files.models.file_share import FileShare
16
+ from geek_cafe_saas_sdk.domains.files.models.file import File
17
+ import datetime as dt
18
+
19
+
20
+ class FileShareService(DatabaseService[FileShare]):
21
+ """
22
+ File share service for permission-based file sharing.
23
+
24
+ Handles:
25
+ - Creating file shares with permissions
26
+ - Access validation
27
+ - Share expiration
28
+ - Share revocation
29
+ - Permission management (view, download, edit)
30
+ """
31
+
32
+ def create(
33
+ self,
34
+ tenant_id: str,
35
+ user_id: str,
36
+ file_id: str,
37
+ shared_with_user_id: str,
38
+ permission: str = "view",
39
+ expires_at: Optional[float] = None,
40
+ message: Optional[str] = None,
41
+ **kwargs
42
+ ) -> ServiceResult[FileShare]:
43
+ """
44
+ Create a file share.
45
+
46
+ Args:
47
+ tenant_id: Tenant ID
48
+ user_id: User ID (file owner creating the share)
49
+ file_id: File ID to share
50
+ shared_with_user_id: User ID to share with
51
+ permission: Permission level (view, download, edit)
52
+ expires_at: Optional expiration timestamp
53
+ message: Optional message for recipient
54
+
55
+ Returns:
56
+ ServiceResult with FileShare model
57
+ """
58
+ try:
59
+ # Validate permission
60
+ valid_permissions = ["view", "download", "edit"]
61
+ if permission not in valid_permissions:
62
+ raise ValidationError(
63
+ f"Invalid permission: {permission}. Must be one of {valid_permissions}",
64
+ "permission"
65
+ )
66
+
67
+ # Cannot share with self
68
+ if shared_with_user_id == user_id:
69
+ raise ValidationError(
70
+ "Cannot share file with yourself",
71
+ "shared_with_user_id"
72
+ )
73
+
74
+ # Get the file to verify ownership
75
+ file_result = self._get_file(tenant_id, file_id, user_id)
76
+ if not file_result.success:
77
+ return ServiceResult.error_result(
78
+ message="File not found or you do not have permission to share it",
79
+ error_code=ErrorCode.ACCESS_DENIED
80
+ )
81
+
82
+ file = file_result.data
83
+
84
+ # Only owner can share
85
+ if file.owner_id != user_id:
86
+ raise AccessDeniedError("Only the file owner can share this file")
87
+
88
+ # Check for existing share
89
+ existing_share = self._get_existing_share(
90
+ tenant_id, file_id, shared_with_user_id
91
+ )
92
+ if existing_share:
93
+ raise ValidationError(
94
+ "File is already shared with this user",
95
+ "shared_with_user_id"
96
+ )
97
+
98
+ # Create FileShare model
99
+ share = FileShare()
100
+ share.prep_for_save()
101
+ share.tenant_id = tenant_id
102
+ share.file_id = file_id
103
+ share.shared_by = user_id
104
+ share.shared_with_user_id = shared_with_user_id
105
+ share.permission_level = permission
106
+ share.expires_at = expires_at
107
+ share.message = message
108
+ share.status = "active"
109
+ share.access_count = 0
110
+
111
+ # Save to DynamoDB
112
+ pk = f"FILE#{tenant_id}#{file_id}"
113
+ sk = f"SHARE#{share.share_id}"
114
+
115
+ item = share.to_dictionary()
116
+ item["pk"] = pk
117
+ item["sk"] = sk
118
+
119
+ # GSI1: Shares by file
120
+ item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
121
+ item["gsi1_sk"] = f"USER#{shared_with_user_id}"
122
+
123
+ # GSI2: Shares with user
124
+ item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{shared_with_user_id}"
125
+ item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
126
+
127
+ self.dynamodb.save(
128
+ item=item,
129
+ table_name=self.table_name
130
+ )
131
+
132
+ return ServiceResult.success_result(share)
133
+
134
+ except (ValidationError, AccessDeniedError) as e:
135
+ return ServiceResult.error_result(
136
+ message=str(e),
137
+ error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
138
+ )
139
+ except Exception as e:
140
+ return ServiceResult.exception_result(
141
+ exception=e,
142
+ error_code=ErrorCode.INTERNAL_ERROR,
143
+ context="FileShareService.create"
144
+ )
145
+
146
+ def get_by_id(
147
+ self,
148
+ resource_id: str,
149
+ tenant_id: str,
150
+ user_id: str,
151
+ file_id: Optional[str] = None
152
+ ) -> ServiceResult[FileShare]:
153
+ """
154
+ Get share by ID.
155
+
156
+ Args:
157
+ resource_id: Share ID
158
+ tenant_id: Tenant ID
159
+ user_id: User ID (for access control)
160
+ file_id: File ID (required)
161
+
162
+ Returns:
163
+ ServiceResult with FileShare model
164
+ """
165
+ try:
166
+ if not file_id:
167
+ raise ValidationError("file_id is required", "file_id")
168
+
169
+ pk = f"FILE#{tenant_id}#{file_id}"
170
+ sk = f"SHARE#{resource_id}"
171
+
172
+ result = self.dynamodb.get(
173
+ table_name=self.table_name,
174
+ key={"pk": pk, "sk": sk}
175
+ )
176
+
177
+ if not result or 'Item' not in result:
178
+ raise NotFoundError(f"Share not found: {resource_id}")
179
+
180
+ share = FileShare()
181
+ share.map(result['Item'])
182
+
183
+ # Access control: user must be sharer or sharee
184
+ if share.shared_by != user_id and share.shared_with_user_id != user_id:
185
+ raise AccessDeniedError("You do not have access to this share")
186
+
187
+ return ServiceResult.success_result(share)
188
+
189
+ except (NotFoundError, ValidationError) as e:
190
+ return ServiceResult.error_result(
191
+ message=str(e),
192
+ error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.VALIDATION_ERROR
193
+ )
194
+ except AccessDeniedError as e:
195
+ return ServiceResult.error_result(
196
+ message=str(e),
197
+ error_code=ErrorCode.ACCESS_DENIED
198
+ )
199
+ except Exception as e:
200
+ return ServiceResult.exception_result(
201
+ exception=e,
202
+ error_code=ErrorCode.INTERNAL_ERROR,
203
+ context="FileShareService.get_by_id"
204
+ )
205
+
206
+ def update(
207
+ self,
208
+ resource_id: str,
209
+ tenant_id: str,
210
+ user_id: str,
211
+ updates: Dict[str, Any]
212
+ ) -> ServiceResult[FileShare]:
213
+ """
214
+ Update share (permission or expiration).
215
+
216
+ Args:
217
+ resource_id: Share ID
218
+ tenant_id: Tenant ID
219
+ user_id: User ID (must be sharer)
220
+ updates: Dictionary of fields to update
221
+
222
+ Returns:
223
+ ServiceResult with updated FileShare model
224
+ """
225
+ try:
226
+ file_id = updates.get('file_id')
227
+ if not file_id:
228
+ raise ValidationError("file_id is required in updates", "file_id")
229
+
230
+ # Get existing share
231
+ get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
232
+ if not get_result.success:
233
+ return get_result
234
+
235
+ share = get_result.data
236
+
237
+ # Only sharer can update
238
+ if share.shared_by != user_id:
239
+ raise AccessDeniedError("Only the person who shared can update this share")
240
+
241
+ # Apply updates (only allowed fields)
242
+ allowed_fields = ["permission_level", "expires_at", "message"]
243
+
244
+ for field, value in updates.items():
245
+ if field == "permission_level":
246
+ valid_permissions = ["view", "download", "edit"]
247
+ if value not in valid_permissions:
248
+ raise ValidationError(
249
+ f"Invalid permission: {value}",
250
+ "permission_level"
251
+ )
252
+
253
+ if field in allowed_fields:
254
+ setattr(share, field, value)
255
+
256
+ share.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
257
+
258
+ # Save to DynamoDB
259
+ pk = f"FILE#{tenant_id}#{file_id}"
260
+ sk = f"SHARE#{resource_id}"
261
+
262
+ item = share.to_dictionary()
263
+ item["pk"] = pk
264
+ item["sk"] = sk
265
+
266
+ # Preserve GSI keys
267
+ item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
268
+ item["gsi1_sk"] = f"USER#{share.shared_with_user_id}"
269
+ item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{share.shared_with_user_id}"
270
+ item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
271
+
272
+ self.dynamodb.save(
273
+ item=item,
274
+ table_name=self.table_name
275
+ )
276
+
277
+ return ServiceResult.success_result(share)
278
+
279
+ except (ValidationError, AccessDeniedError) as e:
280
+ return ServiceResult.error_result(
281
+ message=str(e),
282
+ error_code=ErrorCode.VALIDATION_ERROR if isinstance(e, ValidationError) else ErrorCode.ACCESS_DENIED
283
+ )
284
+ except Exception as e:
285
+ return ServiceResult.exception_result(
286
+ exception=e,
287
+ error_code=ErrorCode.INTERNAL_ERROR,
288
+ context="FileShareService.update"
289
+ )
290
+
291
+ def delete(
292
+ self,
293
+ resource_id: str,
294
+ tenant_id: str,
295
+ user_id: str,
296
+ file_id: str
297
+ ) -> ServiceResult[bool]:
298
+ """
299
+ Revoke a share.
300
+
301
+ Args:
302
+ resource_id: Share ID
303
+ tenant_id: Tenant ID
304
+ user_id: User ID (must be sharer)
305
+ file_id: File ID
306
+
307
+ Returns:
308
+ ServiceResult with success boolean
309
+ """
310
+ try:
311
+ # Get existing share
312
+ get_result = self.get_by_id(resource_id, tenant_id, user_id, file_id=file_id)
313
+ if not get_result.success:
314
+ return get_result
315
+
316
+ share = get_result.data
317
+
318
+ # Only sharer can revoke
319
+ if share.shared_by != user_id:
320
+ raise AccessDeniedError("Only the person who shared can revoke this share")
321
+
322
+ # Mark as revoked
323
+ share.status = "revoked"
324
+ share.revoked_at = dt.datetime.now(dt.UTC).timestamp()
325
+
326
+ pk = f"FILE#{tenant_id}#{file_id}"
327
+ sk = f"SHARE#{resource_id}"
328
+
329
+ item = share.to_dictionary()
330
+ item["pk"] = pk
331
+ item["sk"] = sk
332
+
333
+ # Preserve GSI keys
334
+ item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
335
+ item["gsi1_sk"] = f"USER#{share.shared_with_user_id}"
336
+ item["gsi2_pk"] = f"TENANT#{tenant_id}#USER#{share.shared_with_user_id}"
337
+ item["gsi2_sk"] = f"FILE#{file_id}#{share.created_utc_ts}"
338
+
339
+ self.dynamodb.save(
340
+ item=item,
341
+ table_name=self.table_name
342
+ )
343
+
344
+ return ServiceResult.success_result(True)
345
+
346
+ except AccessDeniedError as e:
347
+ return ServiceResult.error_result(
348
+ message=str(e),
349
+ error_code=ErrorCode.ACCESS_DENIED
350
+ )
351
+ except Exception as e:
352
+ return ServiceResult.exception_result(
353
+ exception=e,
354
+ error_code=ErrorCode.INTERNAL_ERROR,
355
+ context="FileShareService.delete"
356
+ )
357
+
358
+ def list_shares_by_file(
359
+ self,
360
+ tenant_id: str,
361
+ file_id: str,
362
+ user_id: str,
363
+ limit: int = 50
364
+ ) -> ServiceResult[List[FileShare]]:
365
+ """
366
+ List all shares for a file.
367
+
368
+ Args:
369
+ tenant_id: Tenant ID
370
+ file_id: File ID
371
+ user_id: User ID (must be file owner)
372
+ limit: Maximum number of results
373
+
374
+ Returns:
375
+ ServiceResult with list of FileShare models
376
+ """
377
+ try:
378
+ # Verify user owns the file
379
+ file_result = self._get_file(tenant_id, file_id, user_id)
380
+ if not file_result.success:
381
+ return ServiceResult.error_result(
382
+ message="File not found or access denied",
383
+ error_code=ErrorCode.ACCESS_DENIED
384
+ )
385
+
386
+ # Query GSI1
387
+ gsi1_pk = f"FILE#{tenant_id}#{file_id}"
388
+
389
+ results = self.dynamodb.query(
390
+ key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').begins_with("USER#"),
391
+ table_name=self.table_name,
392
+ index_name="gsi1",
393
+ limit=limit
394
+ )
395
+
396
+ shares = []
397
+ for item in results.get('Items', []):
398
+ share = FileShare()
399
+ share.map(item)
400
+
401
+ # Include active and expired shares, exclude revoked
402
+ if share.status != "revoked":
403
+ shares.append(share)
404
+
405
+ return ServiceResult.success_result(shares)
406
+
407
+ except Exception as e:
408
+ return ServiceResult.exception_result(
409
+ exception=e,
410
+ error_code=ErrorCode.INTERNAL_ERROR,
411
+ context="FileShareService.list_shares_by_file"
412
+ )
413
+
414
+ def list_shares_with_user(
415
+ self,
416
+ tenant_id: str,
417
+ user_id: str,
418
+ limit: int = 50
419
+ ) -> ServiceResult[List[FileShare]]:
420
+ """
421
+ List all files shared with a user.
422
+
423
+ Args:
424
+ tenant_id: Tenant ID
425
+ user_id: User ID
426
+ limit: Maximum number of results
427
+
428
+ Returns:
429
+ ServiceResult with list of FileShare models
430
+ """
431
+ try:
432
+ # Query GSI2
433
+ gsi2_pk = f"TENANT#{tenant_id}#USER#{user_id}"
434
+
435
+ results = self.dynamodb.query(
436
+ key=Key('gsi2_pk').eq(gsi2_pk) & Key('gsi2_sk').begins_with("FILE#"),
437
+ table_name=self.table_name,
438
+ index_name="gsi2",
439
+ limit=limit
440
+ )
441
+
442
+ shares = []
443
+ for item in results.get('Items', []):
444
+ share = FileShare()
445
+ share.map(item)
446
+
447
+ # Only include active, non-expired shares
448
+ if share.is_active:
449
+ shares.append(share)
450
+
451
+ return ServiceResult.success_result(shares)
452
+
453
+ except Exception as e:
454
+ return ServiceResult.exception_result(
455
+ exception=e,
456
+ error_code=ErrorCode.INTERNAL_ERROR,
457
+ context="FileShareService.list_shares_with_user"
458
+ )
459
+
460
+ def check_access(
461
+ self,
462
+ tenant_id: str,
463
+ file_id: str,
464
+ user_id: str,
465
+ required_permission: str = "view"
466
+ ) -> ServiceResult[Dict[str, Any]]:
467
+ """
468
+ Check if user has access to a file.
469
+
470
+ Args:
471
+ tenant_id: Tenant ID
472
+ file_id: File ID
473
+ user_id: User ID
474
+ required_permission: Required permission level
475
+
476
+ Returns:
477
+ ServiceResult with access info (has_access, permission, reason)
478
+ """
479
+ try:
480
+ # Get file
481
+ file_result = self._get_file_any_user(tenant_id, file_id)
482
+ if not file_result.success:
483
+ return ServiceResult.success_result({
484
+ "has_access": False,
485
+ "permission": None,
486
+ "reason": "file_not_found"
487
+ })
488
+
489
+ file = file_result.data
490
+
491
+ # Check if user is owner
492
+ if file.owner_id == user_id:
493
+ return ServiceResult.success_result({
494
+ "has_access": True,
495
+ "permission": "owner",
496
+ "reason": "owner"
497
+ })
498
+
499
+ # Check for active share
500
+ share = self._get_existing_share(tenant_id, file_id, user_id)
501
+ if not share:
502
+ return ServiceResult.success_result({
503
+ "has_access": False,
504
+ "permission": None,
505
+ "reason": "no_share"
506
+ })
507
+
508
+ # Check if share is active
509
+ if not share.is_active:
510
+ reason = "expired" if share.is_expired else "revoked"
511
+ return ServiceResult.success_result({
512
+ "has_access": False,
513
+ "permission": None,
514
+ "reason": reason
515
+ })
516
+
517
+ # Check permission level
518
+ permission_levels = {"view": 1, "download": 2, "edit": 3}
519
+ user_level = permission_levels.get(share.permission_level, 0)
520
+ required_level = permission_levels.get(required_permission, 1)
521
+
522
+ has_access = user_level >= required_level
523
+
524
+ # Increment access count if accessing
525
+ if has_access:
526
+ self._increment_access_count(tenant_id, file_id, share.share_id)
527
+
528
+ return ServiceResult.success_result({
529
+ "has_access": has_access,
530
+ "permission": share.permission_level,
531
+ "reason": "granted" if has_access else "insufficient_permission"
532
+ })
533
+
534
+ except Exception as e:
535
+ return ServiceResult.exception_result(
536
+ exception=e,
537
+ error_code=ErrorCode.INTERNAL_ERROR,
538
+ context="FileShareService.check_access"
539
+ )
540
+
541
+ # Helper methods
542
+
543
+ def _get_file(self, tenant_id: str, file_id: str, user_id: str) -> ServiceResult[File]:
544
+ """Get file with access control."""
545
+ try:
546
+ pk = f"FILE#{tenant_id}#{file_id}"
547
+ sk = "METADATA"
548
+
549
+ result = self.dynamodb.get(
550
+ table_name=self.table_name,
551
+ key={"pk": pk, "sk": sk}
552
+ )
553
+
554
+ if not result or 'Item' not in result:
555
+ raise NotFoundError(f"File not found: {file_id}")
556
+
557
+ file = File()
558
+ file.map(result['Item'])
559
+
560
+ if file.owner_id != user_id:
561
+ raise AccessDeniedError("You do not have access to this file")
562
+
563
+ return ServiceResult.success_result(file)
564
+
565
+ except (NotFoundError, AccessDeniedError) as e:
566
+ return ServiceResult.error_result(
567
+ message=str(e),
568
+ error_code=ErrorCode.NOT_FOUND if isinstance(e, NotFoundError) else ErrorCode.ACCESS_DENIED
569
+ )
570
+
571
+ def _get_file_any_user(self, tenant_id: str, file_id: str) -> ServiceResult[File]:
572
+ """Get file without access control (for internal use)."""
573
+ try:
574
+ pk = f"FILE#{tenant_id}#{file_id}"
575
+ sk = "METADATA"
576
+
577
+ result = self.dynamodb.get(
578
+ table_name=self.table_name,
579
+ key={"pk": pk, "sk": sk}
580
+ )
581
+
582
+ if not result or 'Item' not in result:
583
+ raise NotFoundError(f"File not found: {file_id}")
584
+
585
+ file = File()
586
+ file.map(result['Item'])
587
+
588
+ return ServiceResult.success_result(file)
589
+
590
+ except NotFoundError as e:
591
+ return ServiceResult.error_result(
592
+ message=str(e),
593
+ error_code=ErrorCode.NOT_FOUND
594
+ )
595
+
596
+ def _get_existing_share(
597
+ self,
598
+ tenant_id: str,
599
+ file_id: str,
600
+ shared_with_user_id: str
601
+ ) -> Optional[FileShare]:
602
+ """Check if share already exists."""
603
+ try:
604
+ gsi1_pk = f"FILE#{tenant_id}#{file_id}"
605
+ gsi1_sk = f"USER#{shared_with_user_id}"
606
+
607
+ results = self.dynamodb.query(
608
+ key=Key('gsi1_pk').eq(gsi1_pk) & Key('gsi1_sk').eq(gsi1_sk),
609
+ table_name=self.table_name,
610
+ index_name="gsi1",
611
+ limit=1
612
+ )
613
+
614
+ items = results.get('Items', [])
615
+ if items:
616
+ share = FileShare()
617
+ share.map(items[0])
618
+ return share
619
+
620
+ return None
621
+
622
+ except Exception:
623
+ return None
624
+
625
+ def _increment_access_count(
626
+ self,
627
+ tenant_id: str,
628
+ file_id: str,
629
+ share_id: str
630
+ ) -> None:
631
+ """Increment share access count."""
632
+ try:
633
+ pk = f"FILE#{tenant_id}#{file_id}"
634
+ sk = f"SHARE#{share_id}"
635
+
636
+ result = self.dynamodb.get(
637
+ table_name=self.table_name,
638
+ key={"pk": pk, "sk": sk}
639
+ )
640
+
641
+ if result and 'Item' in result:
642
+ share = FileShare()
643
+ share.map(result['Item'])
644
+
645
+ share.access_count += 1
646
+ share.last_accessed_at = dt.datetime.now(dt.UTC).timestamp()
647
+
648
+ item = share.to_dictionary()
649
+ item["pk"] = pk
650
+ item["sk"] = sk
651
+
652
+ # Preserve GSI keys
653
+ item["gsi1_pk"] = result['Item'].get('gsi1_pk')
654
+ item["gsi1_sk"] = result['Item'].get('gsi1_sk')
655
+ item["gsi2_pk"] = result['Item'].get('gsi2_pk')
656
+ item["gsi2_sk"] = result['Item'].get('gsi2_sk')
657
+
658
+ self.dynamodb.save(
659
+ item=item,
660
+ table_name=self.table_name
661
+ )
662
+ except Exception:
663
+ pass # Best effort