geek-cafe-saas-sdk 0.7.0__py3-none-any.whl → 0.7.1__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 +37 -120
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +40 -102
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
- 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.1.dist-info}/METADATA +1 -1
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +79 -20
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
- {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NotificationService - Multi-channel notification delivery.
|
|
3
|
+
|
|
4
|
+
Handles notification creation, delivery, state management, and user preferences.
|
|
5
|
+
Supports email, SMS, in-app, push, and webhook channels.
|
|
6
|
+
|
|
7
|
+
Geek Cafe, LLC
|
|
8
|
+
MIT License. See Project Root for the license information.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from typing import Optional, Dict, List, Any
|
|
13
|
+
from boto3_assist.dynamodb.dynamodb import DynamoDB
|
|
14
|
+
from boto3.dynamodb.conditions import Key
|
|
15
|
+
from geek_cafe_saas_sdk.core.service_result import ServiceResult
|
|
16
|
+
from geek_cafe_saas_sdk.core.error_codes import ErrorCode
|
|
17
|
+
from geek_cafe_saas_sdk.services.database_service import DatabaseService
|
|
18
|
+
from geek_cafe_saas_sdk.domains.notifications.models import (
|
|
19
|
+
Notification,
|
|
20
|
+
NotificationPreference,
|
|
21
|
+
WebhookSubscription
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotificationService(DatabaseService[Notification]):
|
|
26
|
+
"""
|
|
27
|
+
Service for managing notifications and multi-channel delivery.
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
- Multi-channel delivery (email, SMS, push, in-app, webhook)
|
|
31
|
+
- User preference management
|
|
32
|
+
- Quiet hours and DND support
|
|
33
|
+
- Retry logic
|
|
34
|
+
- State tracking
|
|
35
|
+
- Webhook subscriptions
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, dynamodb: Optional[DynamoDB] = None, table_name: Optional[str] = None):
|
|
39
|
+
super().__init__(dynamodb=dynamodb, table_name=table_name)
|
|
40
|
+
|
|
41
|
+
# ========================================================================
|
|
42
|
+
# Abstract Method Implementations (Required by DatabaseService)
|
|
43
|
+
# ========================================================================
|
|
44
|
+
|
|
45
|
+
def create(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[Notification]:
|
|
46
|
+
"""Create a new notification. Delegates to create_notification."""
|
|
47
|
+
return self.create_notification(
|
|
48
|
+
tenant_id=tenant_id,
|
|
49
|
+
notification_type=kwargs.get('notification_type', 'general'),
|
|
50
|
+
channel=kwargs.get('channel', 'in_app'),
|
|
51
|
+
recipient_id=kwargs.get('recipient_id', user_id),
|
|
52
|
+
**kwargs
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[Notification]:
|
|
56
|
+
"""Get notification by ID. Delegates to get_notification."""
|
|
57
|
+
return self.get_notification(tenant_id=tenant_id, notification_id=resource_id)
|
|
58
|
+
|
|
59
|
+
def update(self, resource_id: str, tenant_id: str, user_id: str, updates: Dict[str, Any]) -> ServiceResult[Notification]:
|
|
60
|
+
"""Update notification. Delegates to update_notification_state."""
|
|
61
|
+
return self.update_notification_state(
|
|
62
|
+
tenant_id=tenant_id,
|
|
63
|
+
notification_id=resource_id,
|
|
64
|
+
**updates
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
|
|
68
|
+
"""Delete (archive) notification by updating its state."""
|
|
69
|
+
try:
|
|
70
|
+
result = self.update_notification_state(
|
|
71
|
+
tenant_id=tenant_id,
|
|
72
|
+
notification_id=resource_id,
|
|
73
|
+
state='archived'
|
|
74
|
+
)
|
|
75
|
+
return ServiceResult.success_result(result.success)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "delete_notification")
|
|
78
|
+
|
|
79
|
+
# ========================================================================
|
|
80
|
+
# Notification Operations
|
|
81
|
+
# ========================================================================
|
|
82
|
+
|
|
83
|
+
def create_notification(
|
|
84
|
+
self,
|
|
85
|
+
tenant_id: str,
|
|
86
|
+
notification_type: str,
|
|
87
|
+
channel: str,
|
|
88
|
+
recipient_id: str,
|
|
89
|
+
body: str,
|
|
90
|
+
**kwargs
|
|
91
|
+
) -> ServiceResult[Notification]:
|
|
92
|
+
"""
|
|
93
|
+
Create a new notification.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
tenant_id: Tenant ID
|
|
97
|
+
notification_type: Type identifier (e.g., "payment_receipt")
|
|
98
|
+
channel: Delivery channel (email, sms, push, in_app, webhook)
|
|
99
|
+
recipient_id: User ID receiving notification
|
|
100
|
+
body: Notification content
|
|
101
|
+
**kwargs: Additional fields (subject, title, template_id, etc.)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
ServiceResult with Notification
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
notification = Notification().map(kwargs)
|
|
108
|
+
notification.tenant_id = tenant_id
|
|
109
|
+
notification.notification_type = notification_type
|
|
110
|
+
notification.channel = channel
|
|
111
|
+
notification.recipient_id = recipient_id
|
|
112
|
+
notification.body = body
|
|
113
|
+
|
|
114
|
+
# Set queued timestamp
|
|
115
|
+
notification.queued_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
116
|
+
|
|
117
|
+
# Validate
|
|
118
|
+
is_valid, errors = notification.validate()
|
|
119
|
+
if not is_valid:
|
|
120
|
+
return ServiceResult.error_result(
|
|
121
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
122
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Save using helper method - automatically handles pk/sk from _setup_indexes()
|
|
126
|
+
notification.prep_for_save()
|
|
127
|
+
return self._save_model(notification)
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_notification")
|
|
131
|
+
|
|
132
|
+
def get_notification(
|
|
133
|
+
self,
|
|
134
|
+
tenant_id: str,
|
|
135
|
+
notification_id: str
|
|
136
|
+
) -> ServiceResult[Notification]:
|
|
137
|
+
"""Get a notification by ID."""
|
|
138
|
+
try:
|
|
139
|
+
# Use helper to get notification with tenant check
|
|
140
|
+
notification = self._get_model_by_id_with_tenant_check(
|
|
141
|
+
notification_id, Notification, tenant_id
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not notification:
|
|
145
|
+
return ServiceResult.error_result(
|
|
146
|
+
message=f"Notification not found: {notification_id}",
|
|
147
|
+
error_code=ErrorCode.NOT_FOUND
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return ServiceResult.success_result(notification)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_notification")
|
|
154
|
+
|
|
155
|
+
def update_notification_state(
|
|
156
|
+
self,
|
|
157
|
+
tenant_id: str,
|
|
158
|
+
notification_id: str,
|
|
159
|
+
state: str,
|
|
160
|
+
**kwargs
|
|
161
|
+
) -> ServiceResult[Notification]:
|
|
162
|
+
"""
|
|
163
|
+
Update notification state.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
tenant_id: Tenant ID
|
|
167
|
+
notification_id: Notification ID
|
|
168
|
+
state: New state (sent, delivered, failed, etc.)
|
|
169
|
+
**kwargs: Additional fields (error_code, provider_message_id, etc.)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
ServiceResult with updated Notification
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
result = self.get_notification(tenant_id, notification_id)
|
|
176
|
+
if not result.success:
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
notification = result.data
|
|
180
|
+
notification = notification.map(kwargs)
|
|
181
|
+
notification.state = state
|
|
182
|
+
|
|
183
|
+
# Update timestamp based on state
|
|
184
|
+
now = dt.datetime.now(dt.UTC).timestamp()
|
|
185
|
+
if state == Notification.STATE_SENT:
|
|
186
|
+
notification.sent_utc_ts = now
|
|
187
|
+
elif state == Notification.STATE_DELIVERED:
|
|
188
|
+
notification.delivered_utc_ts = now
|
|
189
|
+
elif state == Notification.STATE_FAILED:
|
|
190
|
+
notification.failed_utc_ts = now
|
|
191
|
+
|
|
192
|
+
# Save
|
|
193
|
+
notification.updated_utc_ts = now
|
|
194
|
+
notification.version += 1
|
|
195
|
+
notification.prep_for_save()
|
|
196
|
+
|
|
197
|
+
return self._save_model(notification)
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_notification_state")
|
|
201
|
+
|
|
202
|
+
def list_notifications(
|
|
203
|
+
self,
|
|
204
|
+
recipient_id: str,
|
|
205
|
+
limit: int = 50,
|
|
206
|
+
unread_only: bool = False
|
|
207
|
+
) -> ServiceResult[List[Notification]]:
|
|
208
|
+
"""
|
|
209
|
+
List notifications for a user.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
recipient_id: User ID
|
|
213
|
+
limit: Max results
|
|
214
|
+
unread_only: Filter to unread notifications
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
ServiceResult with list of Notifications
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
# Create temp notification with recipient_id set for index query
|
|
221
|
+
temp_notification = Notification()
|
|
222
|
+
temp_notification.recipient_id = recipient_id
|
|
223
|
+
temp_notification.queued_utc_ts = 0 # Will match all
|
|
224
|
+
|
|
225
|
+
result = self._query_by_index(
|
|
226
|
+
temp_notification,
|
|
227
|
+
"gsi1",
|
|
228
|
+
ascending=False, # Most recent first
|
|
229
|
+
limit=limit
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if not result.success:
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
# Filter unread if requested
|
|
236
|
+
notifications = []
|
|
237
|
+
for notification in result.data:
|
|
238
|
+
if unread_only and notification.read_utc_ts is not None:
|
|
239
|
+
continue
|
|
240
|
+
notifications.append(notification)
|
|
241
|
+
|
|
242
|
+
return ServiceResult.success_result(notifications)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_notifications")
|
|
246
|
+
|
|
247
|
+
def mark_as_read(
|
|
248
|
+
self,
|
|
249
|
+
tenant_id: str,
|
|
250
|
+
notification_id: str
|
|
251
|
+
) -> ServiceResult[Notification]:
|
|
252
|
+
"""Mark an in-app notification as read."""
|
|
253
|
+
try:
|
|
254
|
+
result = self.get_notification(tenant_id, notification_id)
|
|
255
|
+
if not result.success:
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
notification = result.data
|
|
259
|
+
|
|
260
|
+
if notification.channel == Notification.CHANNEL_IN_APP:
|
|
261
|
+
notification.mark_read(dt.datetime.now(dt.UTC).timestamp())
|
|
262
|
+
notification.prep_for_save()
|
|
263
|
+
return self._save_model(notification)
|
|
264
|
+
|
|
265
|
+
return ServiceResult.success_result(notification)
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "mark_as_read")
|
|
269
|
+
|
|
270
|
+
# ========================================================================
|
|
271
|
+
# Preference Operations
|
|
272
|
+
# ========================================================================
|
|
273
|
+
|
|
274
|
+
def get_user_preferences(
|
|
275
|
+
self,
|
|
276
|
+
user_id: str
|
|
277
|
+
) -> ServiceResult[NotificationPreference]:
|
|
278
|
+
"""
|
|
279
|
+
Get user notification preferences.
|
|
280
|
+
|
|
281
|
+
Creates default preferences if none exist.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
preferences = NotificationPreference()
|
|
285
|
+
preferences.user_id = user_id
|
|
286
|
+
|
|
287
|
+
result = self.dynamodb.get(
|
|
288
|
+
table_name=self.table_name,
|
|
289
|
+
model=preferences,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if not result or "Item" not in result:
|
|
293
|
+
# Create default preferences
|
|
294
|
+
preferences = NotificationPreference()
|
|
295
|
+
preferences.user_id = user_id
|
|
296
|
+
return ServiceResult.success_result(preferences)
|
|
297
|
+
|
|
298
|
+
# Use .map() instead of load_from_dictionary
|
|
299
|
+
preferences = NotificationPreference().map(result["Item"])
|
|
300
|
+
|
|
301
|
+
return ServiceResult.success_result(preferences)
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_user_preferences")
|
|
305
|
+
|
|
306
|
+
def update_preferences(
|
|
307
|
+
self,
|
|
308
|
+
user_id: str,
|
|
309
|
+
updates: Dict[str, Any]
|
|
310
|
+
) -> ServiceResult[NotificationPreference]:
|
|
311
|
+
"""
|
|
312
|
+
Update user notification preferences.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
user_id: User ID
|
|
316
|
+
updates: Fields to update
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
ServiceResult with updated NotificationPreference
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
result = self.get_user_preferences(user_id)
|
|
323
|
+
if not result.success:
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
preferences = result.data
|
|
327
|
+
|
|
328
|
+
# Apply updates
|
|
329
|
+
preferences = preferences.map(updates)
|
|
330
|
+
|
|
331
|
+
# Validate
|
|
332
|
+
is_valid, errors = preferences.validate()
|
|
333
|
+
if not is_valid:
|
|
334
|
+
return ServiceResult.error_result(
|
|
335
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
336
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Save
|
|
340
|
+
preferences.prep_for_save()
|
|
341
|
+
return self._save_model(preferences)
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_preferences")
|
|
345
|
+
|
|
346
|
+
def set_type_preference(
|
|
347
|
+
self,
|
|
348
|
+
user_id: str,
|
|
349
|
+
notification_type: str,
|
|
350
|
+
channel: str,
|
|
351
|
+
enabled: bool
|
|
352
|
+
) -> ServiceResult[NotificationPreference]:
|
|
353
|
+
"""Set preference for specific notification type and channel."""
|
|
354
|
+
try:
|
|
355
|
+
result = self.get_user_preferences(user_id)
|
|
356
|
+
if not result.success:
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
preferences = result.data
|
|
360
|
+
preferences.set_type_preference(notification_type, channel, enabled)
|
|
361
|
+
|
|
362
|
+
# Save
|
|
363
|
+
preferences.prep_for_save()
|
|
364
|
+
return self._save_model(preferences)
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "set_type_preference")
|
|
368
|
+
|
|
369
|
+
# ========================================================================
|
|
370
|
+
# Webhook Subscription Operations
|
|
371
|
+
# ========================================================================
|
|
372
|
+
|
|
373
|
+
def create_webhook_subscription(
|
|
374
|
+
self,
|
|
375
|
+
tenant_id: str,
|
|
376
|
+
subscription_name: str,
|
|
377
|
+
url: str,
|
|
378
|
+
event_types: List[str],
|
|
379
|
+
**kwargs
|
|
380
|
+
) -> ServiceResult[WebhookSubscription]:
|
|
381
|
+
"""
|
|
382
|
+
Create a webhook subscription.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
tenant_id: Tenant ID
|
|
386
|
+
subscription_name: Display name
|
|
387
|
+
url: Webhook endpoint URL
|
|
388
|
+
event_types: List of events to subscribe to
|
|
389
|
+
**kwargs: Additional config (secret, headers, etc.)
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
ServiceResult with WebhookSubscription
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
subscription = WebhookSubscription()
|
|
396
|
+
# set optional fields
|
|
397
|
+
subscription = subscription.map(kwargs)
|
|
398
|
+
# set known fields
|
|
399
|
+
subscription.tenant_id = tenant_id
|
|
400
|
+
subscription.subscription_name = subscription_name
|
|
401
|
+
subscription.url = url
|
|
402
|
+
subscription.event_types = event_types
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# Validate
|
|
408
|
+
is_valid, errors = subscription.validate()
|
|
409
|
+
if not is_valid:
|
|
410
|
+
return ServiceResult.error_result(
|
|
411
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
412
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Save
|
|
416
|
+
subscription.prep_for_save()
|
|
417
|
+
return self._save_model(subscription)
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "create_webhook_subscription")
|
|
421
|
+
|
|
422
|
+
def get_webhook_subscription(
|
|
423
|
+
self,
|
|
424
|
+
tenant_id: str,
|
|
425
|
+
subscription_id: str
|
|
426
|
+
) -> ServiceResult[WebhookSubscription]:
|
|
427
|
+
"""Get a webhook subscription by ID."""
|
|
428
|
+
try:
|
|
429
|
+
subscription = self._get_model_by_id_with_tenant_check(
|
|
430
|
+
subscription_id, WebhookSubscription, tenant_id
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if not subscription:
|
|
434
|
+
return ServiceResult.error_result(
|
|
435
|
+
message=f"Webhook subscription not found: {subscription_id}",
|
|
436
|
+
error_code=ErrorCode.NOT_FOUND
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return ServiceResult.success_result(subscription)
|
|
440
|
+
|
|
441
|
+
except Exception as e:
|
|
442
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_webhook_subscription")
|
|
443
|
+
|
|
444
|
+
def update_webhook_subscription(
|
|
445
|
+
self,
|
|
446
|
+
tenant_id: str,
|
|
447
|
+
subscription_id: str,
|
|
448
|
+
updates: Dict[str, Any]
|
|
449
|
+
) -> ServiceResult[WebhookSubscription]:
|
|
450
|
+
"""Update a webhook subscription."""
|
|
451
|
+
try:
|
|
452
|
+
result = self.get_webhook_subscription(tenant_id, subscription_id)
|
|
453
|
+
if not result.success:
|
|
454
|
+
return result
|
|
455
|
+
|
|
456
|
+
subscription = result.data
|
|
457
|
+
|
|
458
|
+
# Apply updates
|
|
459
|
+
for key, value in updates.items():
|
|
460
|
+
if hasattr(subscription, key):
|
|
461
|
+
setattr(subscription, key, value)
|
|
462
|
+
|
|
463
|
+
# Validate
|
|
464
|
+
is_valid, errors = subscription.validate()
|
|
465
|
+
if not is_valid:
|
|
466
|
+
return ServiceResult.error_result(
|
|
467
|
+
message=f"Validation failed: {', '.join(errors)}",
|
|
468
|
+
error_code=ErrorCode.VALIDATION_ERROR
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Save
|
|
472
|
+
subscription.updated_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
473
|
+
subscription.version += 1
|
|
474
|
+
subscription.prep_for_save()
|
|
475
|
+
|
|
476
|
+
return self._save_model(subscription)
|
|
477
|
+
|
|
478
|
+
except Exception as e:
|
|
479
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "update_webhook_subscription")
|
|
480
|
+
|
|
481
|
+
def list_webhook_subscriptions(
|
|
482
|
+
self,
|
|
483
|
+
tenant_id: str,
|
|
484
|
+
active_only: bool = True
|
|
485
|
+
) -> ServiceResult[List[WebhookSubscription]]:
|
|
486
|
+
"""List webhook subscriptions for a tenant."""
|
|
487
|
+
try:
|
|
488
|
+
gsi_pk = f"tenant#{tenant_id}"
|
|
489
|
+
|
|
490
|
+
results = self.dynamodb.query(
|
|
491
|
+
key=Key("gsi1_pk").eq(gsi_pk) & Key("gsi1_sk").begins_with("WEBHOOK#"),
|
|
492
|
+
table_name=self.table_name,
|
|
493
|
+
index_name="gsi1"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
subscriptions = []
|
|
497
|
+
for item in results.get("Items", []):
|
|
498
|
+
# Use .map() instead of load_from_dictionary
|
|
499
|
+
subscription = WebhookSubscription().map(item)
|
|
500
|
+
|
|
501
|
+
if active_only and not subscription.is_active():
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
subscriptions.append(subscription)
|
|
505
|
+
|
|
506
|
+
return ServiceResult.success_result(subscriptions)
|
|
507
|
+
|
|
508
|
+
except Exception as e:
|
|
509
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "list_webhook_subscriptions")
|
|
510
|
+
|
|
511
|
+
# ========================================================================
|
|
512
|
+
# Delivery Helper Methods
|
|
513
|
+
# ========================================================================
|
|
514
|
+
|
|
515
|
+
def should_send(
|
|
516
|
+
self,
|
|
517
|
+
notification: Notification,
|
|
518
|
+
preferences: NotificationPreference,
|
|
519
|
+
current_time: Optional[str] = None
|
|
520
|
+
) -> tuple[bool, str]:
|
|
521
|
+
"""
|
|
522
|
+
Check if notification should be sent based on preferences.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Tuple of (should_send: bool, reason: str)
|
|
526
|
+
"""
|
|
527
|
+
# Urgent notifications bypass all preference checks except global disable
|
|
528
|
+
if notification.priority == Notification.PRIORITY_URGENT:
|
|
529
|
+
if not preferences.enabled:
|
|
530
|
+
return (False, "Notifications disabled")
|
|
531
|
+
# Skip DND, channel preferences, type preferences, and quiet hours for urgent
|
|
532
|
+
# Continue to expiration and scheduling checks below
|
|
533
|
+
else:
|
|
534
|
+
# Non-urgent: apply all preference checks
|
|
535
|
+
if not preferences.enabled:
|
|
536
|
+
return (False, "Notifications disabled")
|
|
537
|
+
|
|
538
|
+
# Check DND
|
|
539
|
+
if preferences.do_not_disturb:
|
|
540
|
+
return (False, "Do not disturb enabled")
|
|
541
|
+
|
|
542
|
+
# Check channel enabled
|
|
543
|
+
if not preferences.is_channel_enabled(notification.channel):
|
|
544
|
+
return (False, f"Channel {notification.channel} disabled")
|
|
545
|
+
|
|
546
|
+
# Check type-specific preference
|
|
547
|
+
if not preferences.is_type_enabled(notification.notification_type, notification.channel):
|
|
548
|
+
return (False, f"Type {notification.notification_type} disabled for {notification.channel}")
|
|
549
|
+
|
|
550
|
+
# Check quiet hours
|
|
551
|
+
if current_time and preferences.is_in_quiet_hours(current_time):
|
|
552
|
+
return (False, "In quiet hours")
|
|
553
|
+
|
|
554
|
+
# Check expiration
|
|
555
|
+
now = dt.datetime.now(dt.UTC).timestamp()
|
|
556
|
+
if notification.is_expired(now):
|
|
557
|
+
return (False, "Notification expired")
|
|
558
|
+
|
|
559
|
+
# Check scheduling
|
|
560
|
+
if not notification.should_send_now(now):
|
|
561
|
+
return (False, "Scheduled for later")
|
|
562
|
+
|
|
563
|
+
return (True, "OK")
|
|
564
|
+
|
|
565
|
+
def get_unread_count(self, recipient_id: str) -> ServiceResult[int]:
|
|
566
|
+
"""Get count of unread in-app notifications."""
|
|
567
|
+
try:
|
|
568
|
+
result = self.list_notifications(recipient_id, limit=1000, unread_only=True)
|
|
569
|
+
if not result.success:
|
|
570
|
+
return result
|
|
571
|
+
|
|
572
|
+
count = len(result.data)
|
|
573
|
+
return ServiceResult.success_result(count)
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
576
|
+
return ServiceResult.exception_result(e, ErrorCode.INTERNAL_ERROR, "get_unread_count")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Payment domain.
|
|
2
|
+
|
|
3
|
+
Geek Cafe, LLC
|
|
4
|
+
MIT License. See Project Root for the license information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import BillingAccount, PaymentIntentRef, Payment, Refund
|
|
8
|
+
from .services import PaymentService
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BillingAccount",
|
|
12
|
+
"PaymentIntentRef",
|
|
13
|
+
"Payment",
|
|
14
|
+
"Refund",
|
|
15
|
+
"PaymentService",
|
|
16
|
+
]
|