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.

Files changed (79) hide show
  1. geek_cafe_saas_sdk/__init__.py +1 -1
  2. geek_cafe_saas_sdk/domains/files/models/directory.py +42 -6
  3. geek_cafe_saas_sdk/domains/files/models/file.py +40 -4
  4. geek_cafe_saas_sdk/domains/files/models/file_share.py +33 -0
  5. geek_cafe_saas_sdk/domains/files/models/file_version.py +24 -0
  6. geek_cafe_saas_sdk/domains/files/services/directory_service.py +54 -135
  7. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +37 -120
  8. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +40 -102
  9. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +44 -124
  10. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +55 -7
  11. geek_cafe_saas_sdk/domains/notifications/__init__.py +18 -0
  12. geek_cafe_saas_sdk/domains/notifications/handlers/__init__.py +1 -0
  13. geek_cafe_saas_sdk/domains/notifications/handlers/create_webhook/app.py +73 -0
  14. geek_cafe_saas_sdk/domains/notifications/handlers/get/app.py +40 -0
  15. geek_cafe_saas_sdk/domains/notifications/handlers/get_preferences/app.py +34 -0
  16. geek_cafe_saas_sdk/domains/notifications/handlers/list/app.py +43 -0
  17. geek_cafe_saas_sdk/domains/notifications/handlers/list_webhooks/app.py +40 -0
  18. geek_cafe_saas_sdk/domains/notifications/handlers/mark_read/app.py +40 -0
  19. geek_cafe_saas_sdk/domains/notifications/handlers/send/app.py +83 -0
  20. geek_cafe_saas_sdk/domains/notifications/handlers/update_preferences/app.py +45 -0
  21. geek_cafe_saas_sdk/domains/notifications/models/__init__.py +16 -0
  22. geek_cafe_saas_sdk/domains/notifications/models/notification.py +717 -0
  23. geek_cafe_saas_sdk/domains/notifications/models/notification_preference.py +365 -0
  24. geek_cafe_saas_sdk/domains/notifications/models/webhook_subscription.py +339 -0
  25. geek_cafe_saas_sdk/domains/notifications/services/__init__.py +10 -0
  26. geek_cafe_saas_sdk/domains/notifications/services/notification_service.py +576 -0
  27. geek_cafe_saas_sdk/domains/payments/__init__.py +16 -0
  28. geek_cafe_saas_sdk/domains/payments/handlers/README.md +334 -0
  29. geek_cafe_saas_sdk/domains/payments/handlers/__init__.py +6 -0
  30. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/create/app.py +105 -0
  31. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/get/app.py +60 -0
  32. geek_cafe_saas_sdk/domains/payments/handlers/billing_accounts/update/app.py +97 -0
  33. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/create/app.py +97 -0
  34. geek_cafe_saas_sdk/domains/payments/handlers/payment_intents/get/app.py +60 -0
  35. geek_cafe_saas_sdk/domains/payments/handlers/payments/get/app.py +60 -0
  36. geek_cafe_saas_sdk/domains/payments/handlers/payments/list/app.py +68 -0
  37. geek_cafe_saas_sdk/domains/payments/handlers/payments/record/app.py +118 -0
  38. geek_cafe_saas_sdk/domains/payments/handlers/refunds/create/app.py +89 -0
  39. geek_cafe_saas_sdk/domains/payments/handlers/refunds/get/app.py +60 -0
  40. geek_cafe_saas_sdk/domains/payments/models/__init__.py +17 -0
  41. geek_cafe_saas_sdk/domains/payments/models/billing_account.py +521 -0
  42. geek_cafe_saas_sdk/domains/payments/models/payment.py +639 -0
  43. geek_cafe_saas_sdk/domains/payments/models/payment_intent_ref.py +539 -0
  44. geek_cafe_saas_sdk/domains/payments/models/refund.py +404 -0
  45. geek_cafe_saas_sdk/domains/payments/services/__init__.py +11 -0
  46. geek_cafe_saas_sdk/domains/payments/services/payment_service.py +405 -0
  47. geek_cafe_saas_sdk/domains/subscriptions/__init__.py +19 -0
  48. geek_cafe_saas_sdk/domains/subscriptions/handlers/README.md +408 -0
  49. geek_cafe_saas_sdk/domains/subscriptions/handlers/__init__.py +1 -0
  50. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/create/app.py +81 -0
  51. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/get/app.py +48 -0
  52. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/list/app.py +54 -0
  53. geek_cafe_saas_sdk/domains/subscriptions/handlers/addons/update/app.py +54 -0
  54. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/create/app.py +83 -0
  55. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/get/app.py +47 -0
  56. geek_cafe_saas_sdk/domains/subscriptions/handlers/discounts/validate/app.py +62 -0
  57. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/create/app.py +82 -0
  58. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/get/app.py +48 -0
  59. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/list/app.py +66 -0
  60. geek_cafe_saas_sdk/domains/subscriptions/handlers/plans/update/app.py +54 -0
  61. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/aggregate/app.py +72 -0
  62. geek_cafe_saas_sdk/domains/subscriptions/handlers/usage/record/app.py +89 -0
  63. geek_cafe_saas_sdk/domains/subscriptions/models/__init__.py +13 -0
  64. geek_cafe_saas_sdk/domains/subscriptions/models/addon.py +604 -0
  65. geek_cafe_saas_sdk/domains/subscriptions/models/discount.py +492 -0
  66. geek_cafe_saas_sdk/domains/subscriptions/models/plan.py +569 -0
  67. geek_cafe_saas_sdk/domains/subscriptions/models/usage_record.py +300 -0
  68. geek_cafe_saas_sdk/domains/subscriptions/services/__init__.py +10 -0
  69. geek_cafe_saas_sdk/domains/subscriptions/services/subscription_manager_service.py +694 -0
  70. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +123 -1
  71. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +213 -0
  72. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +7 -0
  73. geek_cafe_saas_sdk/services/database_service.py +10 -6
  74. geek_cafe_saas_sdk/utilities/environment_variables.py +16 -0
  75. geek_cafe_saas_sdk/utilities/logging_utility.py +77 -0
  76. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/METADATA +1 -1
  77. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/RECORD +79 -20
  78. {geek_cafe_saas_sdk-0.7.0.dist-info → geek_cafe_saas_sdk-0.7.1.dist-info}/WHEEL +0 -0
  79. {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,717 @@
1
+ """
2
+ Notification Model - Multi-channel notification delivery system.
3
+
4
+ Supports email, SMS, in-app, push notifications with state tracking,
5
+ priority management, and retry logic.
6
+
7
+ Geek Cafe, LLC
8
+ MIT License. See Project Root for the license information.
9
+ """
10
+
11
+ from typing import Optional, Dict, List, Any
12
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
13
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
14
+
15
+
16
+ class Notification(BaseModel):
17
+ """
18
+ Notification model for multi-channel message delivery.
19
+
20
+ Handles state management (queued→sending→sent→delivered→failed),
21
+ retry logic, priority, and channel-specific configuration.
22
+
23
+ Channels supported:
24
+ - email: Via SES, SendGrid, etc.
25
+ - sms: Via Twilio, SNS, etc.
26
+ - in_app: Browser/mobile app notifications
27
+ - push: Mobile push notifications (FCM, APNS)
28
+
29
+ States:
30
+ - queued: Created, waiting to send
31
+ - sending: Currently being sent
32
+ - sent: Successfully sent to provider
33
+ - delivered: Confirmed delivery (when available)
34
+ - failed: Delivery failed
35
+ - cancelled: Cancelled before sending
36
+ """
37
+
38
+ # Channel types
39
+ CHANNEL_EMAIL = "email"
40
+ CHANNEL_SMS = "sms"
41
+ CHANNEL_IN_APP = "in_app"
42
+ CHANNEL_PUSH = "push"
43
+ CHANNEL_WEBHOOK = "webhook"
44
+
45
+ # States
46
+ STATE_QUEUED = "queued"
47
+ STATE_SENDING = "sending"
48
+ STATE_SENT = "sent"
49
+ STATE_DELIVERED = "delivered"
50
+ STATE_FAILED = "failed"
51
+ STATE_CANCELLED = "cancelled"
52
+
53
+ # Priority levels
54
+ PRIORITY_LOW = "low"
55
+ PRIORITY_NORMAL = "normal"
56
+ PRIORITY_HIGH = "high"
57
+ PRIORITY_URGENT = "urgent"
58
+
59
+ def __init__(self):
60
+ super().__init__()
61
+
62
+ # Core fields
63
+ self._notification_type: str = "" # "welcome_email", "payment_receipt", "alert", etc.
64
+ self._channel: str = self.CHANNEL_EMAIL # Delivery channel
65
+ self._state: str = self.STATE_QUEUED # Current state
66
+ self._priority: str = self.PRIORITY_NORMAL
67
+
68
+ # Recipient
69
+ self._recipient_id: str | None = None # User ID receiving notification
70
+ self._recipient_email: str | None = None
71
+ self._recipient_phone: str | None = None
72
+ self._recipient_device_token: str | None = None # For push
73
+ self._recipient_name: str | None = None
74
+
75
+ # Content
76
+ self._subject: str | None = None # For email
77
+ self._title: str | None = None # For push/in-app
78
+ self._body: str = "" # Main content
79
+ self._body_html: str | None = None # HTML version for email
80
+ self._template_id: str | None = None # Template reference
81
+ self._template_data: Dict[str, Any] = {} # Template variables
82
+
83
+ # Delivery configuration
84
+ self._send_after_utc_ts: float | None = None # Scheduled delivery
85
+ self._expires_utc_ts: float | None = None # Don't send after this time
86
+ self._max_retries: int = 3
87
+ self._retry_count: int = 0
88
+ self._retry_delay_seconds: int = 300 # 5 minutes
89
+
90
+ # Channel-specific config
91
+ self._email_config: Dict[str, Any] = {} # reply-to, cc, bcc, attachments
92
+ self._sms_config: Dict[str, Any] = {} # sender_id, message_type
93
+ self._push_config: Dict[str, Any] = {} # badge, sound, data
94
+ self._webhook_config: Dict[str, Any] = {} # url, method, headers
95
+
96
+ # State tracking
97
+ self._queued_utc_ts: float | None = None
98
+ self._sent_utc_ts: float | None = None
99
+ self._delivered_utc_ts: float | None = None
100
+ self._failed_utc_ts: float | None = None
101
+ self._read_utc_ts: float | None = None # For in-app
102
+
103
+ # Provider tracking
104
+ self._provider: str | None = None # "ses", "twilio", "fcm", etc.
105
+ self._provider_message_id: str | None = None # External ID
106
+ self._provider_response: Dict[str, Any] = {}
107
+
108
+ # Error tracking
109
+ self._error_code: str | None = None
110
+ self._error_message: str | None = None
111
+ self._error_details: Dict[str, Any] = {}
112
+
113
+ # Metadata
114
+ self._triggered_by_event: str | None = None # Event that triggered this
115
+ self._related_resource_type: str | None = None # "payment", "subscription", etc.
116
+ self._related_resource_id: str | None = None
117
+ self._campaign_id: str | None = None # Marketing campaign tracking
118
+ self._tags: List[str] = []
119
+ self._metadata: Dict[str, Any] = {}
120
+
121
+ # CRITICAL: Call _setup_indexes() as LAST line in __init__
122
+ self._setup_indexes()
123
+
124
+ def _setup_indexes(self):
125
+ """Setup DynamoDB indexes for notification queries."""
126
+
127
+ # Primary index: Notification by ID
128
+ primary: DynamoDBIndex = DynamoDBIndex()
129
+ primary.name = "primary"
130
+ primary.partition_key.attribute_name = "pk"
131
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("notification", self.id))
132
+ primary.sort_key.attribute_name = "sk"
133
+ primary.sort_key.value = lambda: "metadata"
134
+ self.indexes.add_primary(primary)
135
+
136
+ # GSI1: Notifications by recipient (for user's notification list)
137
+ gsi: DynamoDBIndex = DynamoDBIndex()
138
+ gsi.name = "gsi1"
139
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
140
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("recipient", self.recipient_id))
141
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
142
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("queued", self.queued_utc_ts))
143
+ self.indexes.add_secondary(gsi)
144
+
145
+ # GSI2: Notifications by tenant and state (for processing queue)
146
+ gsi: DynamoDBIndex = DynamoDBIndex()
147
+ gsi.name = "gsi2"
148
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
149
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.tenant_id))
150
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
151
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(("state", self.state), ("queued", self.queued_utc_ts))
152
+ self.indexes.add_secondary(gsi)
153
+
154
+ # ========================================================================
155
+ # Core Properties
156
+ # ========================================================================
157
+
158
+ @property
159
+ def notification_type(self) -> str:
160
+ """Notification type identifier."""
161
+ return self._notification_type
162
+
163
+ @notification_type.setter
164
+ def notification_type(self, value: str):
165
+ self._notification_type = value
166
+
167
+ @property
168
+ def channel(self) -> str:
169
+ """Delivery channel (email, sms, in_app, push, webhook)."""
170
+ return self._channel
171
+
172
+ @channel.setter
173
+ def channel(self, value: str):
174
+ self._channel = value
175
+
176
+ @property
177
+ def state(self) -> str:
178
+ """Current state."""
179
+ return self._state
180
+
181
+ @state.setter
182
+ def state(self, value: str):
183
+ self._state = value
184
+
185
+ @property
186
+ def priority(self) -> str:
187
+ """Priority level."""
188
+ return self._priority
189
+
190
+ @priority.setter
191
+ def priority(self, value: str):
192
+ self._priority = value
193
+
194
+ # ========================================================================
195
+ # Recipient Properties
196
+ # ========================================================================
197
+
198
+ @property
199
+ def recipient_id(self) -> str | None:
200
+ """User ID."""
201
+ return self._recipient_id
202
+
203
+ @recipient_id.setter
204
+ def recipient_id(self, value: str | None):
205
+ self._recipient_id = value
206
+
207
+ @property
208
+ def recipient_email(self) -> str | None:
209
+ """Email address."""
210
+ return self._recipient_email
211
+
212
+ @recipient_email.setter
213
+ def recipient_email(self, value: str | None):
214
+ self._recipient_email = value
215
+
216
+ @property
217
+ def recipient_phone(self) -> str | None:
218
+ """Phone number."""
219
+ return self._recipient_phone
220
+
221
+ @recipient_phone.setter
222
+ def recipient_phone(self, value: str | None):
223
+ self._recipient_phone = value
224
+
225
+ @property
226
+ def recipient_device_token(self) -> str | None:
227
+ """Push notification device token."""
228
+ return self._recipient_device_token
229
+
230
+ @recipient_device_token.setter
231
+ def recipient_device_token(self, value: str | None):
232
+ self._recipient_device_token = value
233
+
234
+ @property
235
+ def recipient_name(self) -> str | None:
236
+ """Recipient display name."""
237
+ return self._recipient_name
238
+
239
+ @recipient_name.setter
240
+ def recipient_name(self, value: str | None):
241
+ self._recipient_name = value
242
+
243
+ # ========================================================================
244
+ # Content Properties
245
+ # ========================================================================
246
+
247
+ @property
248
+ def subject(self) -> str | None:
249
+ """Email subject line."""
250
+ return self._subject
251
+
252
+ @subject.setter
253
+ def subject(self, value: str | None):
254
+ self._subject = value
255
+
256
+ @property
257
+ def title(self) -> str | None:
258
+ """Push/in-app notification title."""
259
+ return self._title
260
+
261
+ @title.setter
262
+ def title(self, value: str | None):
263
+ self._title = value
264
+
265
+ @property
266
+ def body(self) -> str:
267
+ """Main content body."""
268
+ return self._body
269
+
270
+ @body.setter
271
+ def body(self, value: str):
272
+ self._body = value
273
+
274
+ @property
275
+ def body_html(self) -> str | None:
276
+ """HTML body for email."""
277
+ return self._body_html
278
+
279
+ @body_html.setter
280
+ def body_html(self, value: str | None):
281
+ self._body_html = value
282
+
283
+ @property
284
+ def template_id(self) -> str | None:
285
+ """Template identifier."""
286
+ return self._template_id
287
+
288
+ @template_id.setter
289
+ def template_id(self, value: str | None):
290
+ self._template_id = value
291
+
292
+ @property
293
+ def template_data(self) -> Dict[str, Any]:
294
+ """Template variables."""
295
+ return self._template_data
296
+
297
+ @template_data.setter
298
+ def template_data(self, value: Dict[str, Any]):
299
+ self._template_data = value if value else {}
300
+
301
+ # ========================================================================
302
+ # Delivery Configuration Properties
303
+ # ========================================================================
304
+
305
+ @property
306
+ def send_after_utc_ts(self) -> float | None:
307
+ """Scheduled send time."""
308
+ return self._send_after_utc_ts
309
+
310
+ @send_after_utc_ts.setter
311
+ def send_after_utc_ts(self, value: float | None):
312
+ self._send_after_utc_ts = value
313
+
314
+ @property
315
+ def expires_utc_ts(self) -> float | None:
316
+ """Expiration time."""
317
+ return self._expires_utc_ts
318
+
319
+ @expires_utc_ts.setter
320
+ def expires_utc_ts(self, value: float | None):
321
+ self._expires_utc_ts = value
322
+
323
+ @property
324
+ def max_retries(self) -> int:
325
+ """Maximum retry attempts."""
326
+ return self._max_retries
327
+
328
+ @max_retries.setter
329
+ def max_retries(self, value: int):
330
+ self._max_retries = value
331
+
332
+ @property
333
+ def retry_count(self) -> int:
334
+ """Current retry count."""
335
+ return self._retry_count
336
+
337
+ @retry_count.setter
338
+ def retry_count(self, value: int):
339
+ self._retry_count = value
340
+
341
+ @property
342
+ def retry_delay_seconds(self) -> int:
343
+ """Retry delay in seconds."""
344
+ return self._retry_delay_seconds
345
+
346
+ @retry_delay_seconds.setter
347
+ def retry_delay_seconds(self, value: int):
348
+ self._retry_delay_seconds = value
349
+
350
+ # ========================================================================
351
+ # Channel Config Properties
352
+ # ========================================================================
353
+
354
+ @property
355
+ def email_config(self) -> Dict[str, Any]:
356
+ """Email-specific configuration."""
357
+ return self._email_config
358
+
359
+ @email_config.setter
360
+ def email_config(self, value: Dict[str, Any]):
361
+ self._email_config = value if value else {}
362
+
363
+ @property
364
+ def sms_config(self) -> Dict[str, Any]:
365
+ """SMS-specific configuration."""
366
+ return self._sms_config
367
+
368
+ @sms_config.setter
369
+ def sms_config(self, value: Dict[str, Any]):
370
+ self._sms_config = value if value else {}
371
+
372
+ @property
373
+ def push_config(self) -> Dict[str, Any]:
374
+ """Push notification configuration."""
375
+ return self._push_config
376
+
377
+ @push_config.setter
378
+ def push_config(self, value: Dict[str, Any]):
379
+ self._push_config = value if value else {}
380
+
381
+ @property
382
+ def webhook_config(self) -> Dict[str, Any]:
383
+ """Webhook configuration."""
384
+ return self._webhook_config
385
+
386
+ @webhook_config.setter
387
+ def webhook_config(self, value: Dict[str, Any]):
388
+ self._webhook_config = value if value else {}
389
+
390
+ # ========================================================================
391
+ # Timestamps
392
+ # ========================================================================
393
+
394
+ @property
395
+ def queued_utc_ts(self) -> float | None:
396
+ """When notification was queued."""
397
+ return self._queued_utc_ts
398
+
399
+ @queued_utc_ts.setter
400
+ def queued_utc_ts(self, value: float | None):
401
+ self._queued_utc_ts = value
402
+
403
+ @property
404
+ def sent_utc_ts(self) -> float | None:
405
+ """When notification was sent."""
406
+ return self._sent_utc_ts
407
+
408
+ @sent_utc_ts.setter
409
+ def sent_utc_ts(self, value: float | None):
410
+ self._sent_utc_ts = value
411
+
412
+ @property
413
+ def delivered_utc_ts(self) -> float | None:
414
+ """When notification was delivered."""
415
+ return self._delivered_utc_ts
416
+
417
+ @delivered_utc_ts.setter
418
+ def delivered_utc_ts(self, value: float | None):
419
+ self._delivered_utc_ts = value
420
+
421
+ @property
422
+ def failed_utc_ts(self) -> float | None:
423
+ """When notification failed."""
424
+ return self._failed_utc_ts
425
+
426
+ @failed_utc_ts.setter
427
+ def failed_utc_ts(self, value: float | None):
428
+ self._failed_utc_ts = value
429
+
430
+ @property
431
+ def read_utc_ts(self) -> float | None:
432
+ """When notification was read (in-app)."""
433
+ return self._read_utc_ts
434
+
435
+ @read_utc_ts.setter
436
+ def read_utc_ts(self, value: float | None):
437
+ self._read_utc_ts = value
438
+
439
+ # ========================================================================
440
+ # Provider Tracking
441
+ # ========================================================================
442
+
443
+ @property
444
+ def provider(self) -> str | None:
445
+ """Provider name."""
446
+ return self._provider
447
+
448
+ @provider.setter
449
+ def provider(self, value: str | None):
450
+ self._provider = value
451
+
452
+ @property
453
+ def provider_message_id(self) -> str | None:
454
+ """Provider's message ID."""
455
+ return self._provider_message_id
456
+
457
+ @provider_message_id.setter
458
+ def provider_message_id(self, value: str | None):
459
+ self._provider_message_id = value
460
+
461
+ @property
462
+ def provider_response(self) -> Dict[str, Any]:
463
+ """Provider's response data."""
464
+ return self._provider_response
465
+
466
+ @provider_response.setter
467
+ def provider_response(self, value: Dict[str, Any]):
468
+ self._provider_response = value if value else {}
469
+
470
+ # ========================================================================
471
+ # Error Tracking
472
+ # ========================================================================
473
+
474
+ @property
475
+ def error_code(self) -> str | None:
476
+ """Error code."""
477
+ return self._error_code
478
+
479
+ @error_code.setter
480
+ def error_code(self, value: str | None):
481
+ self._error_code = value
482
+
483
+ @property
484
+ def error_message(self) -> str | None:
485
+ """Error message."""
486
+ return self._error_message
487
+
488
+ @error_message.setter
489
+ def error_message(self, value: str | None):
490
+ self._error_message = value
491
+
492
+ @property
493
+ def error_details(self) -> Dict[str, Any]:
494
+ """Error details."""
495
+ return self._error_details
496
+
497
+ @error_details.setter
498
+ def error_details(self, value: Dict[str, Any]):
499
+ self._error_details = value if value else {}
500
+
501
+ # ========================================================================
502
+ # Metadata
503
+ # ========================================================================
504
+
505
+ @property
506
+ def triggered_by_event(self) -> str | None:
507
+ """Event that triggered this notification."""
508
+ return self._triggered_by_event
509
+
510
+ @triggered_by_event.setter
511
+ def triggered_by_event(self, value: str | None):
512
+ self._triggered_by_event = value
513
+
514
+ @property
515
+ def related_resource_type(self) -> str | None:
516
+ """Related resource type."""
517
+ return self._related_resource_type
518
+
519
+ @related_resource_type.setter
520
+ def related_resource_type(self, value: str | None):
521
+ self._related_resource_type = value
522
+
523
+ @property
524
+ def related_resource_id(self) -> str | None:
525
+ """Related resource ID."""
526
+ return self._related_resource_id
527
+
528
+ @related_resource_id.setter
529
+ def related_resource_id(self, value: str | None):
530
+ self._related_resource_id = value
531
+
532
+ @property
533
+ def campaign_id(self) -> str | None:
534
+ """Campaign identifier."""
535
+ return self._campaign_id
536
+
537
+ @campaign_id.setter
538
+ def campaign_id(self, value: str | None):
539
+ self._campaign_id = value
540
+
541
+ @property
542
+ def tags(self) -> List[str]:
543
+ """Tags for organization."""
544
+ return self._tags
545
+
546
+ @tags.setter
547
+ def tags(self, value: List[str]):
548
+ self._tags = value if value else []
549
+
550
+ @property
551
+ def metadata(self) -> Dict[str, Any]:
552
+ """Additional metadata."""
553
+ return self._metadata
554
+
555
+ @metadata.setter
556
+ def metadata(self, value: Dict[str, Any]):
557
+ self._metadata = value if value else {}
558
+
559
+ # ========================================================================
560
+ # Helper Methods
561
+ # ========================================================================
562
+
563
+ def is_queued(self) -> bool:
564
+ """Check if notification is queued."""
565
+ return self._state == self.STATE_QUEUED
566
+
567
+ def is_sent(self) -> bool:
568
+ """Check if notification was sent."""
569
+ return self._state in [self.STATE_SENT, self.STATE_DELIVERED]
570
+
571
+ def is_delivered(self) -> bool:
572
+ """Check if notification was delivered."""
573
+ return self._state == self.STATE_DELIVERED
574
+
575
+ def is_failed(self) -> bool:
576
+ """Check if notification failed."""
577
+ return self._state == self.STATE_FAILED
578
+
579
+ def is_cancelled(self) -> bool:
580
+ """Check if notification was cancelled."""
581
+ return self._state == self.STATE_CANCELLED
582
+
583
+ def can_retry(self) -> bool:
584
+ """Check if notification can be retried."""
585
+ return (
586
+ self._state == self.STATE_FAILED and
587
+ self._retry_count < self._max_retries
588
+ )
589
+
590
+ def is_expired(self, current_ts: float) -> bool:
591
+ """Check if notification has expired."""
592
+ if not self._expires_utc_ts:
593
+ return False
594
+ return current_ts > self._expires_utc_ts
595
+
596
+ def should_send_now(self, current_ts: float) -> bool:
597
+ """Check if notification should be sent now."""
598
+ if self._send_after_utc_ts:
599
+ return current_ts >= self._send_after_utc_ts
600
+ return True
601
+
602
+ def mark_sent(self, timestamp: float, provider_id: str | None = None):
603
+ """Mark notification as sent."""
604
+ self._state = self.STATE_SENT
605
+ self._sent_utc_ts = timestamp
606
+ if provider_id:
607
+ self._provider_message_id = provider_id
608
+
609
+ def mark_delivered(self, timestamp: float):
610
+ """Mark notification as delivered."""
611
+ self._state = self.STATE_DELIVERED
612
+ self._delivered_utc_ts = timestamp
613
+
614
+ def mark_failed(self, timestamp: float, error_code: str, error_message: str):
615
+ """Mark notification as failed."""
616
+ self._state = self.STATE_FAILED
617
+ self._failed_utc_ts = timestamp
618
+ self._error_code = error_code
619
+ self._error_message = error_message
620
+ self._retry_count += 1
621
+
622
+ def mark_read(self, timestamp: float):
623
+ """Mark notification as read (for in-app)."""
624
+ self._read_utc_ts = timestamp
625
+
626
+ def cancel(self):
627
+ """Cancel the notification."""
628
+ if self._state == self.STATE_QUEUED:
629
+ self._state = self.STATE_CANCELLED
630
+
631
+ def get_recipient_address(self) -> str | None:
632
+ """Get the appropriate recipient address based on channel."""
633
+ if self._channel == self.CHANNEL_EMAIL:
634
+ return self._recipient_email
635
+ elif self._channel == self.CHANNEL_SMS:
636
+ return self._recipient_phone
637
+ elif self._channel == self.CHANNEL_PUSH:
638
+ return self._recipient_device_token
639
+ elif self._channel == self.CHANNEL_IN_APP:
640
+ return self._recipient_id
641
+ return None
642
+
643
+ # ========================================================================
644
+ # Validation
645
+ # ========================================================================
646
+
647
+ def validate(self) -> tuple[bool, list[str]]:
648
+ """
649
+ Validate notification data.
650
+
651
+ Returns:
652
+ Tuple of (is_valid, list of error messages)
653
+ """
654
+ errors = []
655
+
656
+ # Required fields
657
+ if not self._notification_type:
658
+ errors.append("notification_type is required")
659
+
660
+ if not self._channel:
661
+ errors.append("channel is required")
662
+
663
+ if self._channel not in [
664
+ self.CHANNEL_EMAIL,
665
+ self.CHANNEL_SMS,
666
+ self.CHANNEL_IN_APP,
667
+ self.CHANNEL_PUSH,
668
+ self.CHANNEL_WEBHOOK
669
+ ]:
670
+ errors.append(f"Invalid channel: {self._channel}")
671
+
672
+ # Channel-specific validation
673
+ if self._channel == self.CHANNEL_EMAIL:
674
+ if not self._recipient_email:
675
+ errors.append("recipient_email is required for email channel")
676
+ if not self._subject and not self._template_id:
677
+ errors.append("subject or template_id is required for email")
678
+
679
+ elif self._channel == self.CHANNEL_SMS:
680
+ if not self._recipient_phone:
681
+ errors.append("recipient_phone is required for SMS channel")
682
+
683
+ elif self._channel == self.CHANNEL_PUSH:
684
+ if not self._recipient_device_token:
685
+ errors.append("recipient_device_token is required for push channel")
686
+ if not self._title and not self._template_id:
687
+ errors.append("title or template_id is required for push")
688
+
689
+ elif self._channel == self.CHANNEL_IN_APP:
690
+ if not self._recipient_id:
691
+ errors.append("recipient_id is required for in-app channel")
692
+
693
+ elif self._channel == self.CHANNEL_WEBHOOK:
694
+ if not self._webhook_config.get("url"):
695
+ errors.append("webhook_config.url is required for webhook channel")
696
+
697
+ # Content validation
698
+ if not self._body and not self._template_id:
699
+ errors.append("body or template_id is required")
700
+
701
+ # Priority validation
702
+ if self._priority not in [
703
+ self.PRIORITY_LOW,
704
+ self.PRIORITY_NORMAL,
705
+ self.PRIORITY_HIGH,
706
+ self.PRIORITY_URGENT
707
+ ]:
708
+ errors.append(f"Invalid priority: {self._priority}")
709
+
710
+ # Retry validation
711
+ if self._max_retries < 0:
712
+ errors.append("max_retries must be >= 0")
713
+
714
+ if self._retry_delay_seconds < 0:
715
+ errors.append("retry_delay_seconds must be >= 0")
716
+
717
+ return (len(errors) == 0, errors)