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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NotificationPreference Model - User notification channel preferences.
|
|
3
|
+
|
|
4
|
+
Manages per-user settings for notification delivery including:
|
|
5
|
+
- Channel enablement (email, SMS, push, in-app)
|
|
6
|
+
- Quiet hours/Do Not Disturb
|
|
7
|
+
- Notification type preferences
|
|
8
|
+
- Frequency controls
|
|
9
|
+
|
|
10
|
+
Geek Cafe, LLC
|
|
11
|
+
MIT License. See Project Root for the license information.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Dict, List, Any
|
|
15
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
16
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotificationPreference(BaseModel):
|
|
20
|
+
"""
|
|
21
|
+
User notification preferences model.
|
|
22
|
+
|
|
23
|
+
Controls how and when a user receives notifications across all channels.
|
|
24
|
+
Supports per-channel, per-type preferences with quiet hours.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__()
|
|
29
|
+
|
|
30
|
+
# User identification
|
|
31
|
+
self._user_id: str = "" # Required
|
|
32
|
+
|
|
33
|
+
# Global preferences
|
|
34
|
+
self._enabled: bool = True # Master switch
|
|
35
|
+
self._do_not_disturb: bool = False # Temporary DND
|
|
36
|
+
self._quiet_hours_enabled: bool = False
|
|
37
|
+
self._quiet_hours_start: str | None = None # "22:00"
|
|
38
|
+
self._quiet_hours_end: str | None = None # "08:00"
|
|
39
|
+
self._timezone: str = "UTC"
|
|
40
|
+
|
|
41
|
+
# Channel preferences
|
|
42
|
+
self._email_enabled: bool = True
|
|
43
|
+
self._email_address: str | None = None # Override default
|
|
44
|
+
self._email_frequency: str = "immediate" # immediate, daily_digest, weekly_digest
|
|
45
|
+
|
|
46
|
+
self._sms_enabled: bool = False # Opt-in required
|
|
47
|
+
self._sms_phone: str | None = None
|
|
48
|
+
self._sms_frequency: str = "immediate"
|
|
49
|
+
|
|
50
|
+
self._push_enabled: bool = True
|
|
51
|
+
self._push_device_tokens: List[str] = [] # Multiple devices
|
|
52
|
+
|
|
53
|
+
self._in_app_enabled: bool = True
|
|
54
|
+
|
|
55
|
+
self._webhook_enabled: bool = False
|
|
56
|
+
self._webhook_url: str | None = None
|
|
57
|
+
|
|
58
|
+
# Type-specific preferences
|
|
59
|
+
# Key = notification_type, Value = {channel: enabled}
|
|
60
|
+
self._type_preferences: Dict[str, Dict[str, bool]] = {}
|
|
61
|
+
|
|
62
|
+
# Digest settings
|
|
63
|
+
self._digest_time: str = "09:00" # When to send daily digest
|
|
64
|
+
self._digest_day: str = "monday" # For weekly digest
|
|
65
|
+
|
|
66
|
+
# Metadata
|
|
67
|
+
self._language: str = "en" # Preferred language
|
|
68
|
+
self._metadata: Dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
# CRITICAL: Call _setup_indexes() as LAST line in __init__
|
|
71
|
+
self._setup_indexes()
|
|
72
|
+
|
|
73
|
+
def _setup_indexes(self):
|
|
74
|
+
"""Setup DynamoDB indexes for notification preference queries."""
|
|
75
|
+
|
|
76
|
+
# Primary index: Preference by user ID
|
|
77
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
78
|
+
primary.name = "primary"
|
|
79
|
+
primary.partition_key.attribute_name = "pk"
|
|
80
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("preferences", self.user_id))
|
|
81
|
+
primary.sort_key.attribute_name = "sk"
|
|
82
|
+
primary.sort_key.value = lambda: "metadata"
|
|
83
|
+
self.indexes.add_primary(primary)
|
|
84
|
+
|
|
85
|
+
# Core Properties
|
|
86
|
+
@property
|
|
87
|
+
def user_id(self) -> str:
|
|
88
|
+
return self._user_id
|
|
89
|
+
|
|
90
|
+
@user_id.setter
|
|
91
|
+
def user_id(self, value: str):
|
|
92
|
+
self._user_id = value
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def enabled(self) -> bool:
|
|
96
|
+
return self._enabled
|
|
97
|
+
|
|
98
|
+
@enabled.setter
|
|
99
|
+
def enabled(self, value: bool):
|
|
100
|
+
self._enabled = value
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def do_not_disturb(self) -> bool:
|
|
104
|
+
return self._do_not_disturb
|
|
105
|
+
|
|
106
|
+
@do_not_disturb.setter
|
|
107
|
+
def do_not_disturb(self, value: bool):
|
|
108
|
+
self._do_not_disturb = value
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def quiet_hours_enabled(self) -> bool:
|
|
112
|
+
return self._quiet_hours_enabled
|
|
113
|
+
|
|
114
|
+
@quiet_hours_enabled.setter
|
|
115
|
+
def quiet_hours_enabled(self, value: bool):
|
|
116
|
+
self._quiet_hours_enabled = value
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def quiet_hours_start(self) -> str | None:
|
|
120
|
+
return self._quiet_hours_start
|
|
121
|
+
|
|
122
|
+
@quiet_hours_start.setter
|
|
123
|
+
def quiet_hours_start(self, value: str | None):
|
|
124
|
+
self._quiet_hours_start = value
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def quiet_hours_end(self) -> str | None:
|
|
128
|
+
return self._quiet_hours_end
|
|
129
|
+
|
|
130
|
+
@quiet_hours_end.setter
|
|
131
|
+
def quiet_hours_end(self, value: str | None):
|
|
132
|
+
self._quiet_hours_end = value
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def timezone(self) -> str:
|
|
136
|
+
return self._timezone
|
|
137
|
+
|
|
138
|
+
@timezone.setter
|
|
139
|
+
def timezone(self, value: str):
|
|
140
|
+
self._timezone = value
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def email_enabled(self) -> bool:
|
|
144
|
+
return self._email_enabled
|
|
145
|
+
|
|
146
|
+
@email_enabled.setter
|
|
147
|
+
def email_enabled(self, value: bool):
|
|
148
|
+
self._email_enabled = value
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def email_address(self) -> str | None:
|
|
152
|
+
return self._email_address
|
|
153
|
+
|
|
154
|
+
@email_address.setter
|
|
155
|
+
def email_address(self, value: str | None):
|
|
156
|
+
self._email_address = value
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def email_frequency(self) -> str:
|
|
160
|
+
return self._email_frequency
|
|
161
|
+
|
|
162
|
+
@email_frequency.setter
|
|
163
|
+
def email_frequency(self, value: str):
|
|
164
|
+
self._email_frequency = value
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def sms_enabled(self) -> bool:
|
|
168
|
+
return self._sms_enabled
|
|
169
|
+
|
|
170
|
+
@sms_enabled.setter
|
|
171
|
+
def sms_enabled(self, value: bool):
|
|
172
|
+
self._sms_enabled = value
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def sms_phone(self) -> str | None:
|
|
176
|
+
return self._sms_phone
|
|
177
|
+
|
|
178
|
+
@sms_phone.setter
|
|
179
|
+
def sms_phone(self, value: str | None):
|
|
180
|
+
self._sms_phone = value
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def sms_frequency(self) -> str:
|
|
184
|
+
return self._sms_frequency
|
|
185
|
+
|
|
186
|
+
@sms_frequency.setter
|
|
187
|
+
def sms_frequency(self, value: str):
|
|
188
|
+
self._sms_frequency = value
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def push_enabled(self) -> bool:
|
|
192
|
+
return self._push_enabled
|
|
193
|
+
|
|
194
|
+
@push_enabled.setter
|
|
195
|
+
def push_enabled(self, value: bool):
|
|
196
|
+
self._push_enabled = value
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def push_device_tokens(self) -> List[str]:
|
|
200
|
+
return self._push_device_tokens
|
|
201
|
+
|
|
202
|
+
@push_device_tokens.setter
|
|
203
|
+
def push_device_tokens(self, value: List[str]):
|
|
204
|
+
self._push_device_tokens = value if value else []
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def in_app_enabled(self) -> bool:
|
|
208
|
+
return self._in_app_enabled
|
|
209
|
+
|
|
210
|
+
@in_app_enabled.setter
|
|
211
|
+
def in_app_enabled(self, value: bool):
|
|
212
|
+
self._in_app_enabled = value
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def webhook_enabled(self) -> bool:
|
|
216
|
+
return self._webhook_enabled
|
|
217
|
+
|
|
218
|
+
@webhook_enabled.setter
|
|
219
|
+
def webhook_enabled(self, value: bool):
|
|
220
|
+
self._webhook_enabled = value
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def webhook_url(self) -> str | None:
|
|
224
|
+
return self._webhook_url
|
|
225
|
+
|
|
226
|
+
@webhook_url.setter
|
|
227
|
+
def webhook_url(self, value: str | None):
|
|
228
|
+
self._webhook_url = value
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def type_preferences(self) -> Dict[str, Dict[str, bool]]:
|
|
232
|
+
return self._type_preferences
|
|
233
|
+
|
|
234
|
+
@type_preferences.setter
|
|
235
|
+
def type_preferences(self, value: Dict[str, Dict[str, bool]]):
|
|
236
|
+
self._type_preferences = value if value else {}
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def digest_time(self) -> str:
|
|
240
|
+
return self._digest_time
|
|
241
|
+
|
|
242
|
+
@digest_time.setter
|
|
243
|
+
def digest_time(self, value: str):
|
|
244
|
+
self._digest_time = value
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def digest_day(self) -> str:
|
|
248
|
+
return self._digest_day
|
|
249
|
+
|
|
250
|
+
@digest_day.setter
|
|
251
|
+
def digest_day(self, value: str):
|
|
252
|
+
self._digest_day = value
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def language(self) -> str:
|
|
256
|
+
return self._language
|
|
257
|
+
|
|
258
|
+
@language.setter
|
|
259
|
+
def language(self, value: str):
|
|
260
|
+
self._language = value
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def metadata(self) -> Dict[str, Any]:
|
|
264
|
+
return self._metadata
|
|
265
|
+
|
|
266
|
+
@metadata.setter
|
|
267
|
+
def metadata(self, value: Dict[str, Any]):
|
|
268
|
+
self._metadata = value if value else {}
|
|
269
|
+
|
|
270
|
+
# Helper Methods
|
|
271
|
+
def is_channel_enabled(self, channel: str) -> bool:
|
|
272
|
+
"""Check if a channel is globally enabled."""
|
|
273
|
+
if not self._enabled or self._do_not_disturb:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
channel_map = {
|
|
277
|
+
"email": self._email_enabled,
|
|
278
|
+
"sms": self._sms_enabled,
|
|
279
|
+
"push": self._push_enabled,
|
|
280
|
+
"in_app": self._in_app_enabled,
|
|
281
|
+
"webhook": self._webhook_enabled
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return channel_map.get(channel, False)
|
|
285
|
+
|
|
286
|
+
def is_type_enabled(self, notification_type: str, channel: str) -> bool:
|
|
287
|
+
"""Check if a specific notification type is enabled for a channel."""
|
|
288
|
+
if not self.is_channel_enabled(channel):
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
if notification_type in self._type_preferences:
|
|
292
|
+
type_prefs = self._type_preferences[notification_type]
|
|
293
|
+
return type_prefs.get(channel, True)
|
|
294
|
+
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def set_type_preference(self, notification_type: str, channel: str, enabled: bool):
|
|
298
|
+
"""Set preference for a notification type on a specific channel."""
|
|
299
|
+
if notification_type not in self._type_preferences:
|
|
300
|
+
self._type_preferences[notification_type] = {}
|
|
301
|
+
|
|
302
|
+
self._type_preferences[notification_type][channel] = enabled
|
|
303
|
+
|
|
304
|
+
def is_in_quiet_hours(self, current_time: str) -> bool:
|
|
305
|
+
"""Check if current time is within quiet hours (HH:MM format)."""
|
|
306
|
+
if not self._quiet_hours_enabled:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
if not self._quiet_hours_start or not self._quiet_hours_end:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
current = self._time_to_minutes(current_time)
|
|
314
|
+
start = self._time_to_minutes(self._quiet_hours_start)
|
|
315
|
+
end = self._time_to_minutes(self._quiet_hours_end)
|
|
316
|
+
|
|
317
|
+
if start > end: # Overnight hours
|
|
318
|
+
return current >= start or current <= end
|
|
319
|
+
else:
|
|
320
|
+
return start <= current <= end
|
|
321
|
+
except:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
def _time_to_minutes(self, time_str: str) -> int:
|
|
325
|
+
"""Convert HH:MM to minutes since midnight."""
|
|
326
|
+
hours, minutes = map(int, time_str.split(":"))
|
|
327
|
+
return hours * 60 + minutes
|
|
328
|
+
|
|
329
|
+
def add_device_token(self, token: str):
|
|
330
|
+
"""Add a push device token."""
|
|
331
|
+
if token and token not in self._push_device_tokens:
|
|
332
|
+
self._push_device_tokens.append(token)
|
|
333
|
+
|
|
334
|
+
def remove_device_token(self, token: str):
|
|
335
|
+
"""Remove a push device token."""
|
|
336
|
+
if token in self._push_device_tokens:
|
|
337
|
+
self._push_device_tokens.remove(token)
|
|
338
|
+
|
|
339
|
+
def validate(self) -> tuple[bool, list[str]]:
|
|
340
|
+
"""Validate preference data."""
|
|
341
|
+
errors = []
|
|
342
|
+
|
|
343
|
+
if not self._user_id:
|
|
344
|
+
errors.append("user_id is required")
|
|
345
|
+
|
|
346
|
+
# Validate quiet hours format
|
|
347
|
+
if self._quiet_hours_enabled:
|
|
348
|
+
if not self._quiet_hours_start or not self._quiet_hours_end:
|
|
349
|
+
errors.append("quiet_hours_start and quiet_hours_end required when enabled")
|
|
350
|
+
else:
|
|
351
|
+
try:
|
|
352
|
+
self._time_to_minutes(self._quiet_hours_start)
|
|
353
|
+
self._time_to_minutes(self._quiet_hours_end)
|
|
354
|
+
except:
|
|
355
|
+
errors.append("Invalid quiet hours format (use HH:MM)")
|
|
356
|
+
|
|
357
|
+
# Validate SMS phone if enabled
|
|
358
|
+
if self._sms_enabled and not self._sms_phone:
|
|
359
|
+
errors.append("sms_phone required when SMS is enabled")
|
|
360
|
+
|
|
361
|
+
# Validate webhook URL if enabled
|
|
362
|
+
if self._webhook_enabled and not self._webhook_url:
|
|
363
|
+
errors.append("webhook_url required when webhook is enabled")
|
|
364
|
+
|
|
365
|
+
return (len(errors) == 0, errors)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebhookSubscription Model - Outbound webhook event subscriptions.
|
|
3
|
+
|
|
4
|
+
Allows tenants to subscribe to platform events via webhooks for integrations.
|
|
5
|
+
|
|
6
|
+
Geek Cafe, LLC
|
|
7
|
+
MIT License. See Project Root for the license information.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Any
|
|
11
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
12
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookSubscription(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Webhook subscription for outbound event notifications.
|
|
18
|
+
|
|
19
|
+
Tenants can subscribe to platform events (payment completed, user created, etc.)
|
|
20
|
+
and receive HTTP POSTs to their endpoint.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Subscription status
|
|
24
|
+
STATUS_ACTIVE = "active"
|
|
25
|
+
STATUS_PAUSED = "paused"
|
|
26
|
+
STATUS_DISABLED = "disabled"
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
# Core fields
|
|
32
|
+
self._tenant_id: str = "" # Required
|
|
33
|
+
self._subscription_name: str = "" # Display name
|
|
34
|
+
self._url: str = "" # Webhook endpoint
|
|
35
|
+
self._status: str = self.STATUS_ACTIVE
|
|
36
|
+
|
|
37
|
+
# Event subscription
|
|
38
|
+
self._event_types: List[str] = [] # Events to subscribe to
|
|
39
|
+
self._event_filters: Dict[str, Any] = {} # Optional filters
|
|
40
|
+
|
|
41
|
+
# Security
|
|
42
|
+
self._secret: str | None = None # For HMAC signature
|
|
43
|
+
self._api_key: str | None = None # Optional API key header
|
|
44
|
+
self._custom_headers: Dict[str, str] = {} # Additional headers
|
|
45
|
+
|
|
46
|
+
# Configuration
|
|
47
|
+
self._http_method: str = "POST" # POST, PUT
|
|
48
|
+
self._content_type: str = "application/json"
|
|
49
|
+
self._timeout_seconds: int = 30
|
|
50
|
+
self._retry_enabled: bool = True
|
|
51
|
+
self._max_retries: int = 3
|
|
52
|
+
self._retry_delay_seconds: int = 60
|
|
53
|
+
|
|
54
|
+
# Statistics
|
|
55
|
+
self._total_deliveries: int = 0
|
|
56
|
+
self._successful_deliveries: int = 0
|
|
57
|
+
self._failed_deliveries: int = 0
|
|
58
|
+
self._last_delivery_utc_ts: float | None = None
|
|
59
|
+
self._last_success_utc_ts: float | None = None
|
|
60
|
+
self._last_failure_utc_ts: float | None = None
|
|
61
|
+
self._last_error: str | None = None
|
|
62
|
+
|
|
63
|
+
# Metadata
|
|
64
|
+
self._description: str | None = None
|
|
65
|
+
self._metadata: Dict[str, Any] = {}
|
|
66
|
+
|
|
67
|
+
# CRITICAL: Call _setup_indexes() as LAST line in __init__
|
|
68
|
+
self._setup_indexes()
|
|
69
|
+
|
|
70
|
+
def _setup_indexes(self):
|
|
71
|
+
"""Setup DynamoDB indexes for webhook subscription queries."""
|
|
72
|
+
|
|
73
|
+
# Primary index: Webhook subscription by ID
|
|
74
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
75
|
+
primary.name = "primary"
|
|
76
|
+
primary.partition_key.attribute_name = "pk"
|
|
77
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("webhook", self.id))
|
|
78
|
+
primary.sort_key.attribute_name = "sk"
|
|
79
|
+
primary.sort_key.value = lambda: "metadata"
|
|
80
|
+
self.indexes.add_primary(primary)
|
|
81
|
+
|
|
82
|
+
# GSI1: Webhooks by tenant (for listing)
|
|
83
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
84
|
+
gsi.name = "gsi1"
|
|
85
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
86
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.tenant_id))
|
|
87
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
88
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("webhook", self.created_utc_ts))
|
|
89
|
+
self.indexes.add_secondary(gsi)
|
|
90
|
+
|
|
91
|
+
# Properties
|
|
92
|
+
@property
|
|
93
|
+
def tenant_id(self) -> str:
|
|
94
|
+
return self._tenant_id
|
|
95
|
+
|
|
96
|
+
@tenant_id.setter
|
|
97
|
+
def tenant_id(self, value: str):
|
|
98
|
+
self._tenant_id = value
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def subscription_name(self) -> str:
|
|
102
|
+
return self._subscription_name
|
|
103
|
+
|
|
104
|
+
@subscription_name.setter
|
|
105
|
+
def subscription_name(self, value: str):
|
|
106
|
+
self._subscription_name = value
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def url(self) -> str:
|
|
110
|
+
return self._url
|
|
111
|
+
|
|
112
|
+
@url.setter
|
|
113
|
+
def url(self, value: str):
|
|
114
|
+
self._url = value
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def status(self) -> str:
|
|
118
|
+
return self._status
|
|
119
|
+
|
|
120
|
+
@status.setter
|
|
121
|
+
def status(self, value: str):
|
|
122
|
+
self._status = value
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def event_types(self) -> List[str]:
|
|
126
|
+
return self._event_types
|
|
127
|
+
|
|
128
|
+
@event_types.setter
|
|
129
|
+
def event_types(self, value: List[str]):
|
|
130
|
+
self._event_types = value if value else []
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def event_filters(self) -> Dict[str, Any]:
|
|
134
|
+
return self._event_filters
|
|
135
|
+
|
|
136
|
+
@event_filters.setter
|
|
137
|
+
def event_filters(self, value: Dict[str, Any]):
|
|
138
|
+
self._event_filters = value if value else {}
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def secret(self) -> str | None:
|
|
142
|
+
return self._secret
|
|
143
|
+
|
|
144
|
+
@secret.setter
|
|
145
|
+
def secret(self, value: str | None):
|
|
146
|
+
self._secret = value
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def api_key(self) -> str | None:
|
|
150
|
+
return self._api_key
|
|
151
|
+
|
|
152
|
+
@api_key.setter
|
|
153
|
+
def api_key(self, value: str | None):
|
|
154
|
+
self._api_key = value
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def custom_headers(self) -> Dict[str, str]:
|
|
158
|
+
return self._custom_headers
|
|
159
|
+
|
|
160
|
+
@custom_headers.setter
|
|
161
|
+
def custom_headers(self, value: Dict[str, str]):
|
|
162
|
+
self._custom_headers = value if value else {}
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def http_method(self) -> str:
|
|
166
|
+
return self._http_method
|
|
167
|
+
|
|
168
|
+
@http_method.setter
|
|
169
|
+
def http_method(self, value: str):
|
|
170
|
+
self._http_method = value
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def content_type(self) -> str:
|
|
174
|
+
return self._content_type
|
|
175
|
+
|
|
176
|
+
@content_type.setter
|
|
177
|
+
def content_type(self, value: str):
|
|
178
|
+
self._content_type = value
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def timeout_seconds(self) -> int:
|
|
182
|
+
return self._timeout_seconds
|
|
183
|
+
|
|
184
|
+
@timeout_seconds.setter
|
|
185
|
+
def timeout_seconds(self, value: int):
|
|
186
|
+
self._timeout_seconds = value
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def retry_enabled(self) -> bool:
|
|
190
|
+
return self._retry_enabled
|
|
191
|
+
|
|
192
|
+
@retry_enabled.setter
|
|
193
|
+
def retry_enabled(self, value: bool):
|
|
194
|
+
self._retry_enabled = value
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def max_retries(self) -> int:
|
|
198
|
+
return self._max_retries
|
|
199
|
+
|
|
200
|
+
@max_retries.setter
|
|
201
|
+
def max_retries(self, value: int):
|
|
202
|
+
self._max_retries = value
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def retry_delay_seconds(self) -> int:
|
|
206
|
+
return self._retry_delay_seconds
|
|
207
|
+
|
|
208
|
+
@retry_delay_seconds.setter
|
|
209
|
+
def retry_delay_seconds(self, value: int):
|
|
210
|
+
self._retry_delay_seconds = value
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def total_deliveries(self) -> int:
|
|
214
|
+
return self._total_deliveries
|
|
215
|
+
|
|
216
|
+
@total_deliveries.setter
|
|
217
|
+
def total_deliveries(self, value: int):
|
|
218
|
+
self._total_deliveries = value
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def successful_deliveries(self) -> int:
|
|
222
|
+
return self._successful_deliveries
|
|
223
|
+
|
|
224
|
+
@successful_deliveries.setter
|
|
225
|
+
def successful_deliveries(self, value: int):
|
|
226
|
+
self._successful_deliveries = value
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def failed_deliveries(self) -> int:
|
|
230
|
+
return self._failed_deliveries
|
|
231
|
+
|
|
232
|
+
@failed_deliveries.setter
|
|
233
|
+
def failed_deliveries(self, value: int):
|
|
234
|
+
self._failed_deliveries = value
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def last_delivery_utc_ts(self) -> float | None:
|
|
238
|
+
return self._last_delivery_utc_ts
|
|
239
|
+
|
|
240
|
+
@last_delivery_utc_ts.setter
|
|
241
|
+
def last_delivery_utc_ts(self, value: float | None):
|
|
242
|
+
self._last_delivery_utc_ts = value
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def last_success_utc_ts(self) -> float | None:
|
|
246
|
+
return self._last_success_utc_ts
|
|
247
|
+
|
|
248
|
+
@last_success_utc_ts.setter
|
|
249
|
+
def last_success_utc_ts(self, value: float | None):
|
|
250
|
+
self._last_success_utc_ts = value
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def last_failure_utc_ts(self) -> float | None:
|
|
254
|
+
return self._last_failure_utc_ts
|
|
255
|
+
|
|
256
|
+
@last_failure_utc_ts.setter
|
|
257
|
+
def last_failure_utc_ts(self, value: float | None):
|
|
258
|
+
self._last_failure_utc_ts = value
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def last_error(self) -> str | None:
|
|
262
|
+
return self._last_error
|
|
263
|
+
|
|
264
|
+
@last_error.setter
|
|
265
|
+
def last_error(self, value: str | None):
|
|
266
|
+
self._last_error = value
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def description(self) -> str | None:
|
|
270
|
+
return self._description
|
|
271
|
+
|
|
272
|
+
@description.setter
|
|
273
|
+
def description(self, value: str | None):
|
|
274
|
+
self._description = value
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def metadata(self) -> Dict[str, Any]:
|
|
278
|
+
return self._metadata
|
|
279
|
+
|
|
280
|
+
@metadata.setter
|
|
281
|
+
def metadata(self, value: Dict[str, Any]):
|
|
282
|
+
self._metadata = value if value else {}
|
|
283
|
+
|
|
284
|
+
# Helper Methods
|
|
285
|
+
def is_active(self) -> bool:
|
|
286
|
+
"""Check if subscription is active."""
|
|
287
|
+
return self._status == self.STATUS_ACTIVE
|
|
288
|
+
|
|
289
|
+
def subscribes_to(self, event_type: str) -> bool:
|
|
290
|
+
"""Check if subscription includes event type."""
|
|
291
|
+
return event_type in self._event_types or "*" in self._event_types
|
|
292
|
+
|
|
293
|
+
def record_success(self, timestamp: float):
|
|
294
|
+
"""Record successful delivery."""
|
|
295
|
+
self._total_deliveries += 1
|
|
296
|
+
self._successful_deliveries += 1
|
|
297
|
+
self._last_delivery_utc_ts = timestamp
|
|
298
|
+
self._last_success_utc_ts = timestamp
|
|
299
|
+
self._last_error = None
|
|
300
|
+
|
|
301
|
+
def record_failure(self, timestamp: float, error: str):
|
|
302
|
+
"""Record failed delivery."""
|
|
303
|
+
self._total_deliveries += 1
|
|
304
|
+
self._failed_deliveries += 1
|
|
305
|
+
self._last_delivery_utc_ts = timestamp
|
|
306
|
+
self._last_failure_utc_ts = timestamp
|
|
307
|
+
self._last_error = error
|
|
308
|
+
|
|
309
|
+
def get_success_rate(self) -> float:
|
|
310
|
+
"""Calculate success rate percentage."""
|
|
311
|
+
if self._total_deliveries == 0:
|
|
312
|
+
return 0.0
|
|
313
|
+
return (self._successful_deliveries / self._total_deliveries) * 100
|
|
314
|
+
|
|
315
|
+
def validate(self) -> tuple[bool, list[str]]:
|
|
316
|
+
"""Validate subscription data."""
|
|
317
|
+
errors = []
|
|
318
|
+
|
|
319
|
+
if not self._tenant_id:
|
|
320
|
+
errors.append("tenant_id is required")
|
|
321
|
+
|
|
322
|
+
if not self._subscription_name:
|
|
323
|
+
errors.append("subscription_name is required")
|
|
324
|
+
|
|
325
|
+
if not self._url:
|
|
326
|
+
errors.append("url is required")
|
|
327
|
+
elif not self._url.startswith(("http://", "https://")):
|
|
328
|
+
errors.append("url must start with http:// or https://")
|
|
329
|
+
|
|
330
|
+
if not self._event_types:
|
|
331
|
+
errors.append("event_types cannot be empty")
|
|
332
|
+
|
|
333
|
+
if self._http_method not in ["POST", "PUT", "PATCH"]:
|
|
334
|
+
errors.append("http_method must be POST, PUT, or PATCH")
|
|
335
|
+
|
|
336
|
+
if self._timeout_seconds < 1 or self._timeout_seconds > 300:
|
|
337
|
+
errors.append("timeout_seconds must be between 1 and 300")
|
|
338
|
+
|
|
339
|
+
return (len(errors) == 0, errors)
|