geek-cafe-saas-sdk 0.7.0__py3-none-any.whl → 0.7.2__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.
- geek_cafe_saas_sdk/__init__.py +1 -1
- geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
- geek_cafe_saas_sdk/domains/files/models/file.py +40 -4
- geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +60 -136
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +43 -104
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +57 -131
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
- geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
- geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
- geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
- geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
- geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
- geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
- geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
- geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
- geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
- geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
- geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
- geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
- geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
- geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
- geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
- geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
- geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
- geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
- geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
- geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
- geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
- geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
- geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
- geek_cafe_saas_sdk/services/database_service.py +10 -6
- geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/METADATA +1 -1
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/RECORD +79 -20
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/WHEEL +0 -0
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -133,21 +133,11 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
133
133
|
self._mark_version_as_not_current(tenant_id, file_id, file.current_version_id)
|
|
134
134
|
|
|
135
135
|
# Save version metadata to DynamoDB
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
item = version.to_dictionary()
|
|
140
|
-
item["pk"] = pk
|
|
141
|
-
item["sk"] = sk
|
|
142
|
-
|
|
143
|
-
# GSI1: Versions by file (ordered by version number)
|
|
144
|
-
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
145
|
-
item["gsi1_sk"] = f"VERSION#{new_version_num:010d}" # Pad for sorting
|
|
136
|
+
version.prep_for_save()
|
|
137
|
+
save_result = self._save_model(version)
|
|
146
138
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
table_name=self.table_name
|
|
150
|
-
)
|
|
139
|
+
if not save_result.success:
|
|
140
|
+
return save_result
|
|
151
141
|
|
|
152
142
|
# Update file record with new current version
|
|
153
143
|
self._update_file_current_version(
|
|
@@ -194,29 +184,22 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
194
184
|
ServiceResult with FileVersion model
|
|
195
185
|
"""
|
|
196
186
|
try:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
pk = f"FILE#{tenant_id}#{file_id}"
|
|
201
|
-
sk = f"VERSION#{resource_id}"
|
|
187
|
+
# Use helper method with tenant check
|
|
188
|
+
version = self._get_model_by_id_with_tenant_check(resource_id, FileVersion, tenant_id)
|
|
202
189
|
|
|
203
|
-
|
|
204
|
-
table_name=self.table_name,
|
|
205
|
-
key={"pk": pk, "sk": sk}
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
# Check if version exists
|
|
209
|
-
if not result or 'Item' not in result:
|
|
190
|
+
if not version:
|
|
210
191
|
raise NotFoundError(f"Version not found: {resource_id}")
|
|
211
192
|
|
|
212
|
-
# Convert to FileVersion model
|
|
213
|
-
version = FileVersion()
|
|
214
|
-
version.map(result['Item'])
|
|
215
|
-
|
|
216
193
|
# Access control: Check file ownership
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
194
|
+
if file_id:
|
|
195
|
+
file_result = self._get_file(tenant_id, file_id, user_id)
|
|
196
|
+
if not file_result.success:
|
|
197
|
+
raise AccessDeniedError("You do not have access to this file version")
|
|
198
|
+
else:
|
|
199
|
+
# If no file_id provided, check using version's file_id
|
|
200
|
+
file_result = self._get_file(tenant_id, version.file_id, user_id)
|
|
201
|
+
if not file_result.success:
|
|
202
|
+
raise AccessDeniedError("You do not have access to this file version")
|
|
220
203
|
|
|
221
204
|
return ServiceResult.success_result(version)
|
|
222
205
|
|
|
@@ -280,23 +263,8 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
280
263
|
version.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
281
264
|
|
|
282
265
|
# Save to DynamoDB
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
item = version.to_dictionary()
|
|
287
|
-
item["pk"] = pk
|
|
288
|
-
item["sk"] = sk
|
|
289
|
-
|
|
290
|
-
# Preserve GSI1 keys
|
|
291
|
-
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
292
|
-
item["gsi1_sk"] = f"VERSION#{version.version_number:010d}"
|
|
293
|
-
|
|
294
|
-
self.dynamodb.save(
|
|
295
|
-
item=item,
|
|
296
|
-
table_name=self.table_name
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
return ServiceResult.success_result(version)
|
|
266
|
+
version.prep_for_save()
|
|
267
|
+
return self._save_model(version)
|
|
300
268
|
|
|
301
269
|
except (ValidationError, AccessDeniedError) as e:
|
|
302
270
|
return ServiceResult.error_result(
|
|
@@ -348,21 +316,11 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
348
316
|
version.status = "archived"
|
|
349
317
|
version.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
350
318
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
item = version.to_dictionary()
|
|
355
|
-
item["pk"] = pk
|
|
356
|
-
item["sk"] = sk
|
|
357
|
-
|
|
358
|
-
# Preserve GSI1 keys
|
|
359
|
-
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
360
|
-
item["gsi1_sk"] = f"VERSION#{version.version_number:010d}"
|
|
319
|
+
version.prep_for_save()
|
|
320
|
+
save_result = self._save_model(version)
|
|
361
321
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
table_name=self.table_name
|
|
365
|
-
)
|
|
322
|
+
if not save_result.success:
|
|
323
|
+
return save_result
|
|
366
324
|
|
|
367
325
|
# Note: S3 file is kept for potential recovery
|
|
368
326
|
|
|
@@ -408,22 +366,19 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
408
366
|
error_code=ErrorCode.ACCESS_DENIED
|
|
409
367
|
)
|
|
410
368
|
|
|
411
|
-
#
|
|
412
|
-
|
|
369
|
+
# Use GSI1 to query versions by file
|
|
370
|
+
temp_version = FileVersion()
|
|
371
|
+
temp_version.file_id = file_id
|
|
413
372
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
table_name=self.table_name,
|
|
417
|
-
index_name="gsi1",
|
|
418
|
-
limit=limit,
|
|
419
|
-
ascending=False # Newest first
|
|
420
|
-
)
|
|
373
|
+
# Query using helper method (descending to get newest first)
|
|
374
|
+
query_result = self._query_by_index(temp_version, "gsi1", limit=limit, ascending=False)
|
|
421
375
|
|
|
376
|
+
if not query_result.success:
|
|
377
|
+
return query_result
|
|
378
|
+
|
|
379
|
+
# Filter results
|
|
422
380
|
versions = []
|
|
423
|
-
for
|
|
424
|
-
version = FileVersion()
|
|
425
|
-
version.map(item)
|
|
426
|
-
|
|
381
|
+
for version in query_result.data:
|
|
427
382
|
# Filter out archived versions (which includes deleted ones)
|
|
428
383
|
if version.status == "active":
|
|
429
384
|
versions.append(version)
|
|
@@ -479,21 +434,11 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
479
434
|
version_to_restore.is_current = True
|
|
480
435
|
version_to_restore.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
481
436
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
item = version_to_restore.to_dictionary()
|
|
486
|
-
item["pk"] = pk
|
|
487
|
-
item["sk"] = sk
|
|
488
|
-
|
|
489
|
-
# Preserve GSI1 keys
|
|
490
|
-
item["gsi1_pk"] = f"FILE#{tenant_id}#{file_id}"
|
|
491
|
-
item["gsi1_sk"] = f"VERSION#{version_to_restore.version_number:010d}"
|
|
437
|
+
version_to_restore.prep_for_save()
|
|
438
|
+
save_result = self._save_model(version_to_restore)
|
|
492
439
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
table_name=self.table_name
|
|
496
|
-
)
|
|
440
|
+
if not save_result.success:
|
|
441
|
+
return save_result
|
|
497
442
|
|
|
498
443
|
# Update file record
|
|
499
444
|
self._update_file_current_version(
|
|
@@ -570,12 +515,13 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
570
515
|
def _get_file(self, tenant_id: str, file_id: str, user_id: str) -> ServiceResult[File]:
|
|
571
516
|
"""Get file with access control."""
|
|
572
517
|
try:
|
|
573
|
-
|
|
574
|
-
|
|
518
|
+
file = File()
|
|
519
|
+
file.id = file_id
|
|
520
|
+
file.tenant_id = tenant_id
|
|
575
521
|
|
|
576
522
|
result = self.dynamodb.get(
|
|
577
523
|
table_name=self.table_name,
|
|
578
|
-
|
|
524
|
+
model=file
|
|
579
525
|
)
|
|
580
526
|
|
|
581
527
|
if not result or 'Item' not in result:
|
|
@@ -598,10 +544,13 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
598
544
|
def _get_latest_version_number(self, tenant_id: str, file_id: str) -> int:
|
|
599
545
|
"""Get the latest version number for a file."""
|
|
600
546
|
try:
|
|
601
|
-
|
|
547
|
+
file = File()
|
|
548
|
+
file.id = file_id
|
|
549
|
+
file.tenant_id = tenant_id
|
|
602
550
|
|
|
551
|
+
key = file.get_key("gsi1")
|
|
603
552
|
results = self.dynamodb.query(
|
|
604
|
-
key=
|
|
553
|
+
key=key,
|
|
605
554
|
table_name=self.table_name,
|
|
606
555
|
index_name="gsi1",
|
|
607
556
|
limit=1,
|
|
@@ -627,12 +576,14 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
627
576
|
) -> None:
|
|
628
577
|
"""Mark a version as not current."""
|
|
629
578
|
try:
|
|
630
|
-
|
|
631
|
-
|
|
579
|
+
version = FileVersion()
|
|
580
|
+
version.id = version_id
|
|
581
|
+
version.tenant_id = tenant_id
|
|
582
|
+
version.file_id = file_id
|
|
632
583
|
|
|
633
584
|
result = self.dynamodb.get(
|
|
634
585
|
table_name=self.table_name,
|
|
635
|
-
|
|
586
|
+
model=version
|
|
636
587
|
)
|
|
637
588
|
|
|
638
589
|
if result and 'Item' in result:
|
|
@@ -640,18 +591,8 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
640
591
|
version.map(result['Item'])
|
|
641
592
|
version.is_current = False
|
|
642
593
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
item["sk"] = sk
|
|
646
|
-
|
|
647
|
-
# Preserve GSI1 keys
|
|
648
|
-
item["gsi1_pk"] = result['Item'].get('gsi1_pk')
|
|
649
|
-
item["gsi1_sk"] = result['Item'].get('gsi1_sk')
|
|
650
|
-
|
|
651
|
-
self.dynamodb.save(
|
|
652
|
-
item=item,
|
|
653
|
-
table_name=self.table_name
|
|
654
|
-
)
|
|
594
|
+
version.prep_for_save()
|
|
595
|
+
self._save_model(version)
|
|
655
596
|
except Exception:
|
|
656
597
|
pass # Best effort
|
|
657
598
|
|
|
@@ -665,7 +606,7 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
665
606
|
"""Update file record with current version info."""
|
|
666
607
|
try:
|
|
667
608
|
pk = f"FILE#{tenant_id}#{file_id}"
|
|
668
|
-
sk = "
|
|
609
|
+
sk = "metadata"
|
|
669
610
|
|
|
670
611
|
result = self.dynamodb.get(
|
|
671
612
|
table_name=self.table_name,
|
|
@@ -680,14 +621,8 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
680
621
|
file.version_count = version_number
|
|
681
622
|
file.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
682
623
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
item["sk"] = sk
|
|
686
|
-
|
|
687
|
-
self.dynamodb.save(
|
|
688
|
-
item=item,
|
|
689
|
-
table_name=self.table_name
|
|
690
|
-
)
|
|
624
|
+
file.prep_for_save()
|
|
625
|
+
self._save_model(file)
|
|
691
626
|
except Exception:
|
|
692
627
|
pass # Best effort
|
|
693
628
|
|
|
@@ -719,17 +654,8 @@ class FileVersionService(DatabaseService[FileVersion]):
|
|
|
719
654
|
version.status = "archived"
|
|
720
655
|
version.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
721
656
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
item_dict = version.to_dictionary()
|
|
726
|
-
item_dict["pk"] = pk
|
|
727
|
-
item_dict["sk"] = sk
|
|
728
|
-
|
|
729
|
-
self.dynamodb.save(
|
|
730
|
-
item=item_dict,
|
|
731
|
-
table_name=self.table_name
|
|
732
|
-
)
|
|
657
|
+
version.prep_for_save()
|
|
658
|
+
self._save_model(version)
|
|
733
659
|
except Exception:
|
|
734
660
|
pass # Best effort
|
|
735
661
|
|
|
@@ -20,6 +20,40 @@ class ContactThreadService(DatabaseService[ContactThread]):
|
|
|
20
20
|
def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
|
|
21
21
|
super().__init__(dynamodb=dynamodb, table_name=table_name)
|
|
22
22
|
|
|
23
|
+
def _user_has_cross_tenant_access(self, user_context: Optional[Dict[str, str]]) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Determine if user has cross-tenant access to contact threads.
|
|
26
|
+
|
|
27
|
+
Users with cross-tenant access can see threads from any tenant:
|
|
28
|
+
- Platform admins (roles contains "platform_admin")
|
|
29
|
+
- Support staff (roles contains "support_staff" or "support_admin")
|
|
30
|
+
- General admins (roles contains "admin")
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
user_context: User context from JWT containing roles
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if user has cross-tenant access, False otherwise
|
|
37
|
+
"""
|
|
38
|
+
if not user_context:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
roles = user_context.get("roles", "")
|
|
42
|
+
|
|
43
|
+
cross_tenant_roles = [
|
|
44
|
+
"platform_admin",
|
|
45
|
+
"support_admin",
|
|
46
|
+
"support_staff",
|
|
47
|
+
"admin"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Check if user has any cross-tenant role
|
|
51
|
+
for role in cross_tenant_roles:
|
|
52
|
+
if role in roles:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
23
57
|
def create(self, tenant_id: str, user_id: str, payload: Dict[str, Any]) -> ServiceResult[ContactThread]:
|
|
24
58
|
"""
|
|
25
59
|
Create a new contact thread from a payload.
|
|
@@ -115,16 +149,22 @@ class ContactThreadService(DatabaseService[ContactThread]):
|
|
|
115
149
|
return self._handle_service_exception(e, 'get_contact_thread', resource_id=resource_id, tenant_id=tenant_id)
|
|
116
150
|
|
|
117
151
|
def list_by_inbox_and_status(self, inbox_id: str, status: str, tenant_id: str,
|
|
118
|
-
priority: str = None, limit: int = 50
|
|
152
|
+
priority: str = None, limit: int = 50,
|
|
153
|
+
user_context: Optional[Dict[str, str]] = None) -> ServiceResult[List[ContactThread]]:
|
|
119
154
|
"""
|
|
120
155
|
List contact threads by inbox and status using GSI1.
|
|
121
156
|
|
|
157
|
+
Supports role-based access control:
|
|
158
|
+
- Admins/support staff see ALL threads in the inbox (cross-tenant access)
|
|
159
|
+
- Regular users only see threads from their own tenant
|
|
160
|
+
|
|
122
161
|
Args:
|
|
123
162
|
inbox_id: Inbox ID (support, sales, etc.)
|
|
124
163
|
status: Status filter (open, in_progress, resolved, closed)
|
|
125
|
-
tenant_id: Tenant ID for filtering
|
|
164
|
+
tenant_id: Tenant ID for filtering (ignored for admins/support)
|
|
126
165
|
priority: Optional priority filter
|
|
127
166
|
limit: Maximum number of results
|
|
167
|
+
user_context: Optional user context with roles for access control
|
|
128
168
|
|
|
129
169
|
Returns:
|
|
130
170
|
ServiceResult with list of ContactThreads
|
|
@@ -148,11 +188,19 @@ class ContactThreadService(DatabaseService[ContactThread]):
|
|
|
148
188
|
if not result.success:
|
|
149
189
|
return result
|
|
150
190
|
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
# Apply role-based filtering
|
|
192
|
+
if self._user_has_cross_tenant_access(user_context):
|
|
193
|
+
# Admins/support: See all threads in the inbox, regardless of tenant
|
|
194
|
+
active_threads = [
|
|
195
|
+
t for t in result.data
|
|
196
|
+
if not t.is_deleted()
|
|
197
|
+
]
|
|
198
|
+
else:
|
|
199
|
+
# Regular users: Only see threads from their own tenant
|
|
200
|
+
active_threads = [
|
|
201
|
+
t for t in result.data
|
|
202
|
+
if not t.is_deleted() and t.tenant_id == tenant_id
|
|
203
|
+
]
|
|
156
204
|
|
|
157
205
|
return ServiceResult.success_result(active_threads)
|
|
158
206
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notifications Domain.
|
|
3
|
+
|
|
4
|
+
Multi-channel notification delivery with user preferences and webhook support.
|
|
5
|
+
|
|
6
|
+
Geek Cafe, LLC
|
|
7
|
+
MIT License. See Project Root for the license information.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .models import Notification, NotificationPreference, WebhookSubscription
|
|
11
|
+
from .services import NotificationService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Notification",
|
|
15
|
+
"NotificationPreference",
|
|
16
|
+
"WebhookSubscription",
|
|
17
|
+
"NotificationService"
|
|
18
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Notifications Domain Handlers
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Lambda handler for creating webhook subscriptions."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=True,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Create webhook subscription.
|
|
18
|
+
|
|
19
|
+
POST /webhooks
|
|
20
|
+
|
|
21
|
+
Body:
|
|
22
|
+
{
|
|
23
|
+
"subscriptionName": "Payment Events",
|
|
24
|
+
"url": "https://example.com/webhooks/payments",
|
|
25
|
+
"eventTypes": ["payment.completed", "payment.failed"],
|
|
26
|
+
"secret": "webhook_secret_key"
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
return handler_wrapper.execute(event, context, create_webhook, injected_service)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_webhook(
|
|
33
|
+
event: Dict[str, Any],
|
|
34
|
+
service: NotificationService,
|
|
35
|
+
user_context: Dict[str, str]
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Business logic for creating webhook."""
|
|
38
|
+
tenant_id = user_context.get("tenant_id")
|
|
39
|
+
payload = event["parsed_body"]
|
|
40
|
+
|
|
41
|
+
subscription_name = payload.get("subscription_name")
|
|
42
|
+
if not subscription_name:
|
|
43
|
+
raise ValueError("subscription_name is required")
|
|
44
|
+
|
|
45
|
+
url = payload.get("url")
|
|
46
|
+
if not url:
|
|
47
|
+
raise ValueError("url is required")
|
|
48
|
+
|
|
49
|
+
event_types = payload.get("event_types")
|
|
50
|
+
if not event_types:
|
|
51
|
+
raise ValueError("event_types is required")
|
|
52
|
+
|
|
53
|
+
# Extract optional fields
|
|
54
|
+
kwargs = {}
|
|
55
|
+
optional_fields = [
|
|
56
|
+
"secret", "api_key", "custom_headers", "http_method", "content_type",
|
|
57
|
+
"timeout_seconds", "retry_enabled", "max_retries", "retry_delay_seconds",
|
|
58
|
+
"event_filters", "description", "metadata"
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
for field in optional_fields:
|
|
62
|
+
if field in payload:
|
|
63
|
+
kwargs[field] = payload[field]
|
|
64
|
+
|
|
65
|
+
result = service.create_webhook_subscription(
|
|
66
|
+
tenant_id=tenant_id,
|
|
67
|
+
subscription_name=subscription_name,
|
|
68
|
+
url=url,
|
|
69
|
+
event_types=event_types,
|
|
70
|
+
**kwargs
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Lambda handler for getting a notification."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=False,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Get a notification by ID.
|
|
18
|
+
|
|
19
|
+
GET /notifications/{notificationId}
|
|
20
|
+
"""
|
|
21
|
+
return handler_wrapper.execute(event, context, get_notification, injected_service)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_notification(
|
|
25
|
+
event: Dict[str, Any],
|
|
26
|
+
service: NotificationService,
|
|
27
|
+
user_context: Dict[str, str]
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Business logic for getting notification."""
|
|
30
|
+
tenant_id = user_context.get("tenant_id")
|
|
31
|
+
|
|
32
|
+
path_params = event.get("pathParameters") or {}
|
|
33
|
+
notification_id = path_params.get("notification_id")
|
|
34
|
+
|
|
35
|
+
if not notification_id:
|
|
36
|
+
raise ValueError("notification_id is required in path")
|
|
37
|
+
|
|
38
|
+
result = service.get_notification(tenant_id, notification_id)
|
|
39
|
+
|
|
40
|
+
return result
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Lambda handler for getting user notification preferences."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=False,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Get user notification preferences.
|
|
18
|
+
|
|
19
|
+
GET /notifications/preferences
|
|
20
|
+
"""
|
|
21
|
+
return handler_wrapper.execute(event, context, get_preferences, injected_service)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_preferences(
|
|
25
|
+
event: Dict[str, Any],
|
|
26
|
+
service: NotificationService,
|
|
27
|
+
user_context: Dict[str, str]
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Business logic for getting preferences."""
|
|
30
|
+
user_id = user_context.get("user_id")
|
|
31
|
+
|
|
32
|
+
result = service.get_user_preferences(user_id)
|
|
33
|
+
|
|
34
|
+
return result
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Lambda handler for listing notifications."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=False,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
List notifications for current user.
|
|
18
|
+
|
|
19
|
+
GET /notifications?unreadOnly=true&limit=50
|
|
20
|
+
"""
|
|
21
|
+
return handler_wrapper.execute(event, context, list_notifications, injected_service)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_notifications(
|
|
25
|
+
event: Dict[str, Any],
|
|
26
|
+
service: NotificationService,
|
|
27
|
+
user_context: Dict[str, str]
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Business logic for listing notifications."""
|
|
30
|
+
user_id = user_context.get("user_id")
|
|
31
|
+
|
|
32
|
+
params = event.get("queryStringParameters") or {}
|
|
33
|
+
|
|
34
|
+
limit = int(params.get("limit", "50"))
|
|
35
|
+
unread_only = params.get("unread_only", "false").lower() == "true"
|
|
36
|
+
|
|
37
|
+
result = service.list_notifications(
|
|
38
|
+
recipient_id=user_id,
|
|
39
|
+
limit=limit,
|
|
40
|
+
unread_only=unread_only
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Lambda handler for listing webhook subscriptions."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=False,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
List webhook subscriptions.
|
|
18
|
+
|
|
19
|
+
GET /webhooks?activeOnly=true
|
|
20
|
+
"""
|
|
21
|
+
return handler_wrapper.execute(event, context, list_webhooks, injected_service)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_webhooks(
|
|
25
|
+
event: Dict[str, Any],
|
|
26
|
+
service: NotificationService,
|
|
27
|
+
user_context: Dict[str, str]
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Business logic for listing webhooks."""
|
|
30
|
+
tenant_id = user_context.get("tenant_id")
|
|
31
|
+
|
|
32
|
+
params = event.get("queryStringParameters") or {}
|
|
33
|
+
active_only = params.get("active_only", "true").lower() == "true"
|
|
34
|
+
|
|
35
|
+
result = service.list_webhook_subscriptions(
|
|
36
|
+
tenant_id=tenant_id,
|
|
37
|
+
active_only=active_only
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Lambda handler for marking notification as read."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from geek_cafe_saas_sdk.lambda_handlers import create_handler
|
|
5
|
+
from geek_cafe_saas_sdk.domains.notifications.services import NotificationService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
handler_wrapper = create_handler(
|
|
9
|
+
service_class=NotificationService,
|
|
10
|
+
require_body=False,
|
|
11
|
+
convert_case=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handler(event: Dict[str, Any], context: Any, injected_service=None) -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Mark notification as read.
|
|
18
|
+
|
|
19
|
+
PATCH /notifications/{notificationId}/read
|
|
20
|
+
"""
|
|
21
|
+
return handler_wrapper.execute(event, context, mark_read, injected_service)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mark_read(
|
|
25
|
+
event: Dict[str, Any],
|
|
26
|
+
service: NotificationService,
|
|
27
|
+
user_context: Dict[str, str]
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Business logic for marking as read."""
|
|
30
|
+
tenant_id = user_context.get("tenant_id")
|
|
31
|
+
|
|
32
|
+
path_params = event.get("pathParameters") or {}
|
|
33
|
+
notification_id = path_params.get("notification_id")
|
|
34
|
+
|
|
35
|
+
if not notification_id:
|
|
36
|
+
raise ValueError("notification_id is required in path")
|
|
37
|
+
|
|
38
|
+
result = service.mark_as_read(tenant_id, notification_id)
|
|
39
|
+
|
|
40
|
+
return result
|