karaoke-gen 0.103.1__py3-none-any.whl → 0.105.4__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.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/push.py +238 -0
- backend/config.py +9 -1
- backend/main.py +2 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_manager.py +68 -11
- backend/services/push_notification_service.py +409 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +2 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +16 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +25 -18
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
backend/services/job_manager.py
CHANGED
|
@@ -374,22 +374,22 @@ class JobManager:
|
|
|
374
374
|
updates['progress'] = progress
|
|
375
375
|
|
|
376
376
|
# Generate review token when entering AWAITING_REVIEW state
|
|
377
|
+
# Tokens don't expire - they're job-scoped so low risk, and natural expiry happens when job completes
|
|
377
378
|
if new_status == JobStatus.AWAITING_REVIEW:
|
|
378
|
-
from backend.api.dependencies import generate_review_token
|
|
379
|
+
from backend.api.dependencies import generate_review_token
|
|
379
380
|
review_token = generate_review_token()
|
|
380
|
-
review_token_expires = get_review_token_expiry(hours=48) # 48 hour expiry
|
|
381
381
|
updates['review_token'] = review_token
|
|
382
|
-
updates['review_token_expires_at'] =
|
|
383
|
-
logger.info(f"Generated review token for job {job_id}
|
|
384
|
-
|
|
382
|
+
updates['review_token_expires_at'] = None # No expiry - token is job-scoped
|
|
383
|
+
logger.info(f"Generated review token for job {job_id} (no expiry)")
|
|
384
|
+
|
|
385
385
|
# Generate instrumental token when entering AWAITING_INSTRUMENTAL_SELECTION state
|
|
386
|
+
# Tokens don't expire - they're job-scoped so low risk, and natural expiry happens when job completes
|
|
386
387
|
if new_status == JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
|
|
387
|
-
from backend.api.dependencies import generate_review_token
|
|
388
|
+
from backend.api.dependencies import generate_review_token
|
|
388
389
|
instrumental_token = generate_review_token() # Reuse same token generator
|
|
389
|
-
instrumental_token_expires = get_review_token_expiry(hours=48) # 48 hour expiry
|
|
390
390
|
updates['instrumental_token'] = instrumental_token
|
|
391
|
-
updates['instrumental_token_expires_at'] =
|
|
392
|
-
logger.info(f"Generated instrumental token for job {job_id}
|
|
391
|
+
updates['instrumental_token_expires_at'] = None # No expiry - token is job-scoped
|
|
392
|
+
logger.info(f"Generated instrumental token for job {job_id} (no expiry)")
|
|
393
393
|
|
|
394
394
|
# If we have state_data_updates, merge them with existing state_data
|
|
395
395
|
merged_state_data = None
|
|
@@ -438,7 +438,7 @@ class JobManager:
|
|
|
438
438
|
|
|
439
439
|
def _trigger_state_notifications(self, job_id: str, new_status: JobStatus) -> None:
|
|
440
440
|
"""
|
|
441
|
-
Trigger email notifications based on state transitions.
|
|
441
|
+
Trigger email and push notifications based on state transitions.
|
|
442
442
|
|
|
443
443
|
This is fire-and-forget - notification failures don't affect job processing.
|
|
444
444
|
|
|
@@ -458,10 +458,14 @@ class JobManager:
|
|
|
458
458
|
# Job completion notification
|
|
459
459
|
if new_status == JobStatus.COMPLETE:
|
|
460
460
|
self._schedule_completion_email(job)
|
|
461
|
+
self._send_push_notification(job, "complete")
|
|
461
462
|
|
|
462
463
|
# Idle reminder scheduling for blocking states
|
|
463
464
|
elif new_status in [JobStatus.AWAITING_REVIEW, JobStatus.AWAITING_INSTRUMENTAL_SELECTION]:
|
|
464
465
|
self._schedule_idle_reminder(job, new_status)
|
|
466
|
+
# Send push notification for blocking states
|
|
467
|
+
action_type = "lyrics" if new_status == JobStatus.AWAITING_REVIEW else "instrumental"
|
|
468
|
+
self._send_push_notification(job, action_type)
|
|
465
469
|
|
|
466
470
|
except Exception as e:
|
|
467
471
|
# Never let notification failures affect job processing
|
|
@@ -581,7 +585,60 @@ class JobManager:
|
|
|
581
585
|
|
|
582
586
|
except Exception as e:
|
|
583
587
|
logger.error(f"Failed to schedule idle reminder for job {job.job_id}: {e}")
|
|
584
|
-
|
|
588
|
+
|
|
589
|
+
def _send_push_notification(self, job: Job, action_type: str) -> None:
|
|
590
|
+
"""
|
|
591
|
+
Send a push notification for job state changes.
|
|
592
|
+
|
|
593
|
+
Fire-and-forget - failures don't affect job processing.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
job: Job object
|
|
597
|
+
action_type: Type of notification ("lyrics", "instrumental", or "complete")
|
|
598
|
+
"""
|
|
599
|
+
import asyncio
|
|
600
|
+
import threading
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
from backend.services.push_notification_service import get_push_notification_service
|
|
604
|
+
|
|
605
|
+
push_service = get_push_notification_service()
|
|
606
|
+
|
|
607
|
+
# Skip if push notifications not enabled
|
|
608
|
+
if not push_service.is_enabled():
|
|
609
|
+
logger.debug("Push notifications not enabled, skipping")
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
# Build job dict for notification service
|
|
613
|
+
job_dict = {
|
|
614
|
+
"job_id": job.job_id,
|
|
615
|
+
"user_email": job.user_email,
|
|
616
|
+
"artist": job.artist,
|
|
617
|
+
"title": job.title,
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async def send_notification():
|
|
621
|
+
if action_type == "complete":
|
|
622
|
+
await push_service.send_completion_notification(job_dict)
|
|
623
|
+
else:
|
|
624
|
+
await push_service.send_blocking_notification(job_dict, action_type)
|
|
625
|
+
|
|
626
|
+
# Try to get existing event loop, create new one if none exists
|
|
627
|
+
try:
|
|
628
|
+
loop = asyncio.get_running_loop()
|
|
629
|
+
loop.create_task(send_notification())
|
|
630
|
+
except RuntimeError:
|
|
631
|
+
# No event loop - we're in a sync context
|
|
632
|
+
def run_in_thread():
|
|
633
|
+
asyncio.run(send_notification())
|
|
634
|
+
thread = threading.Thread(target=run_in_thread, daemon=True)
|
|
635
|
+
thread.start()
|
|
636
|
+
|
|
637
|
+
logger.debug(f"Scheduled push notification for job {job.job_id} ({action_type})")
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
logger.error(f"Failed to send push notification for job {job.job_id}: {e}")
|
|
641
|
+
|
|
585
642
|
def update_state_data(self, job_id: str, key: str, value: Any) -> None:
|
|
586
643
|
"""
|
|
587
644
|
Update a specific key in the job's state_data field.
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Push Notification Service for Web Push Notifications.
|
|
3
|
+
|
|
4
|
+
Handles sending push notifications to users' browsers/devices using the Web Push protocol.
|
|
5
|
+
Uses pywebpush library for VAPID authentication and push message encryption.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
|
|
11
|
+
from pywebpush import webpush, WebPushException
|
|
12
|
+
|
|
13
|
+
from backend.config import get_settings
|
|
14
|
+
from backend.models.user import User, PushSubscription
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SubscriptionGoneError(Exception):
|
|
21
|
+
"""Raised when a push subscription is no longer valid (410 Gone)."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PushNotificationService:
|
|
26
|
+
"""
|
|
27
|
+
Service for sending Web Push notifications.
|
|
28
|
+
|
|
29
|
+
Requires VAPID keys to be configured in Secret Manager:
|
|
30
|
+
- vapid-public-key: Base64-encoded public key
|
|
31
|
+
- vapid-private-key: Base64-encoded private key
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, db=None):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the push notification service.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
db: Firestore client (optional, will use singleton if not provided)
|
|
40
|
+
"""
|
|
41
|
+
self.settings = get_settings()
|
|
42
|
+
self._vapid_private_key: Optional[str] = None
|
|
43
|
+
self._vapid_public_key: Optional[str] = None
|
|
44
|
+
self._db = db
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def db(self):
|
|
48
|
+
"""Get Firestore client (lazy initialization)."""
|
|
49
|
+
if self._db is None:
|
|
50
|
+
from google.cloud import firestore
|
|
51
|
+
self._db = firestore.Client()
|
|
52
|
+
return self._db
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def vapid_private_key(self) -> Optional[str]:
|
|
56
|
+
"""Get VAPID private key from Secret Manager (cached)."""
|
|
57
|
+
if self._vapid_private_key is None:
|
|
58
|
+
self._vapid_private_key = self.settings.get_secret("vapid-private-key")
|
|
59
|
+
return self._vapid_private_key
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def vapid_public_key(self) -> Optional[str]:
|
|
63
|
+
"""Get VAPID public key from Secret Manager (cached)."""
|
|
64
|
+
if self._vapid_public_key is None:
|
|
65
|
+
self._vapid_public_key = self.settings.get_secret("vapid-public-key")
|
|
66
|
+
return self._vapid_public_key
|
|
67
|
+
|
|
68
|
+
def is_enabled(self) -> bool:
|
|
69
|
+
"""Check if push notifications are enabled and properly configured."""
|
|
70
|
+
return (
|
|
71
|
+
self.settings.enable_push_notifications and
|
|
72
|
+
self.vapid_private_key is not None and
|
|
73
|
+
self.vapid_public_key is not None
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def get_public_key(self) -> Optional[str]:
|
|
77
|
+
"""Get the VAPID public key for client-side subscription."""
|
|
78
|
+
if not self.settings.enable_push_notifications:
|
|
79
|
+
return None
|
|
80
|
+
return self.vapid_public_key
|
|
81
|
+
|
|
82
|
+
async def send_push(
|
|
83
|
+
self,
|
|
84
|
+
user_email: str,
|
|
85
|
+
title: str,
|
|
86
|
+
body: str,
|
|
87
|
+
url: str = "/app/",
|
|
88
|
+
tag: Optional[str] = None
|
|
89
|
+
) -> int:
|
|
90
|
+
"""
|
|
91
|
+
Send a push notification to all of a user's subscribed devices.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
user_email: The user's email address
|
|
95
|
+
title: Notification title
|
|
96
|
+
body: Notification body text
|
|
97
|
+
url: URL to open when notification is clicked
|
|
98
|
+
tag: Optional tag for notification grouping (replaces notifications with same tag)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Number of notifications successfully sent
|
|
102
|
+
"""
|
|
103
|
+
if not self.is_enabled():
|
|
104
|
+
logger.debug("Push notifications not enabled, skipping")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
# Get user from Firestore
|
|
108
|
+
user_doc = self.db.collection("gen_users").document(user_email).get()
|
|
109
|
+
if not user_doc.exists:
|
|
110
|
+
logger.warning(f"User {user_email} not found for push notification")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
user_data = user_doc.to_dict()
|
|
114
|
+
subscriptions = user_data.get("push_subscriptions", [])
|
|
115
|
+
|
|
116
|
+
if not subscriptions:
|
|
117
|
+
logger.debug(f"User {user_email} has no push subscriptions")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
# Build notification payload
|
|
121
|
+
import json
|
|
122
|
+
payload = json.dumps({
|
|
123
|
+
"title": title,
|
|
124
|
+
"body": body,
|
|
125
|
+
"url": url,
|
|
126
|
+
"tag": tag or "default",
|
|
127
|
+
"icon": "/nomad-logo.png"
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
# Send to each subscription
|
|
131
|
+
success_count = 0
|
|
132
|
+
invalid_subscriptions = []
|
|
133
|
+
|
|
134
|
+
for sub in subscriptions:
|
|
135
|
+
try:
|
|
136
|
+
webpush(
|
|
137
|
+
subscription_info={
|
|
138
|
+
"endpoint": sub["endpoint"],
|
|
139
|
+
"keys": sub["keys"]
|
|
140
|
+
},
|
|
141
|
+
data=payload,
|
|
142
|
+
vapid_private_key=self.vapid_private_key,
|
|
143
|
+
vapid_claims={
|
|
144
|
+
"sub": self.settings.vapid_subject
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
success_count += 1
|
|
148
|
+
logger.debug(f"Push sent to {sub.get('device_name', 'unknown device')}")
|
|
149
|
+
|
|
150
|
+
except WebPushException as e:
|
|
151
|
+
logger.warning(f"Push failed: {e}")
|
|
152
|
+
# Check if subscription is gone (410) or unauthorized (401/403)
|
|
153
|
+
if e.response and e.response.status_code in (404, 410):
|
|
154
|
+
logger.info(f"Subscription gone, will remove: {sub['endpoint'][:50]}...")
|
|
155
|
+
invalid_subscriptions.append(sub["endpoint"])
|
|
156
|
+
elif e.response and e.response.status_code in (401, 403):
|
|
157
|
+
logger.warning(f"Push auth failed for {sub['endpoint'][:50]}...")
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"Unexpected error sending push: {e}")
|
|
161
|
+
|
|
162
|
+
# Remove invalid subscriptions
|
|
163
|
+
if invalid_subscriptions:
|
|
164
|
+
await self._remove_invalid_subscriptions(user_email, invalid_subscriptions)
|
|
165
|
+
|
|
166
|
+
logger.info(f"Push notifications sent to {success_count}/{len(subscriptions)} devices for {user_email}")
|
|
167
|
+
return success_count
|
|
168
|
+
|
|
169
|
+
async def _remove_invalid_subscriptions(self, user_email: str, endpoints: List[str]) -> None:
|
|
170
|
+
"""Remove invalid subscriptions from user's list."""
|
|
171
|
+
try:
|
|
172
|
+
user_ref = self.db.collection("gen_users").document(user_email)
|
|
173
|
+
user_doc = user_ref.get()
|
|
174
|
+
if not user_doc.exists:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
user_data = user_doc.to_dict()
|
|
178
|
+
current_subs = user_data.get("push_subscriptions", [])
|
|
179
|
+
|
|
180
|
+
# Filter out invalid subscriptions
|
|
181
|
+
valid_subs = [s for s in current_subs if s["endpoint"] not in endpoints]
|
|
182
|
+
|
|
183
|
+
if len(valid_subs) < len(current_subs):
|
|
184
|
+
user_ref.update({"push_subscriptions": valid_subs})
|
|
185
|
+
logger.info(f"Removed {len(current_subs) - len(valid_subs)} invalid subscriptions for {user_email}")
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"Failed to remove invalid subscriptions: {e}")
|
|
189
|
+
|
|
190
|
+
async def send_blocking_notification(
|
|
191
|
+
self,
|
|
192
|
+
job: dict,
|
|
193
|
+
action_type: str # "lyrics" or "instrumental"
|
|
194
|
+
) -> int:
|
|
195
|
+
"""
|
|
196
|
+
Send notification when a job enters a blocking state requiring user action.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
job: Job dictionary with job_id, user_email, artist, title
|
|
200
|
+
action_type: Type of action needed ("lyrics" or "instrumental")
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Number of notifications sent
|
|
204
|
+
"""
|
|
205
|
+
user_email = job.get("user_email")
|
|
206
|
+
if not user_email:
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
job_id = job.get("job_id", "unknown")
|
|
210
|
+
artist = job.get("artist", "Unknown Artist")
|
|
211
|
+
title = job.get("title", "Unknown Title")
|
|
212
|
+
|
|
213
|
+
if action_type == "lyrics":
|
|
214
|
+
notif_title = "Review Lyrics"
|
|
215
|
+
notif_body = f'"{title}" by {artist} needs lyrics review'
|
|
216
|
+
url = f"/review/{job_id}"
|
|
217
|
+
tag = f"lyrics-{job_id}"
|
|
218
|
+
else: # instrumental
|
|
219
|
+
notif_title = "Select Instrumental"
|
|
220
|
+
notif_body = f'"{title}" by {artist} needs instrumental selection'
|
|
221
|
+
url = f"/instrumental/{job_id}"
|
|
222
|
+
tag = f"instrumental-{job_id}"
|
|
223
|
+
|
|
224
|
+
return await self.send_push(
|
|
225
|
+
user_email=user_email,
|
|
226
|
+
title=notif_title,
|
|
227
|
+
body=notif_body,
|
|
228
|
+
url=url,
|
|
229
|
+
tag=tag
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def send_completion_notification(self, job: dict) -> int:
|
|
233
|
+
"""
|
|
234
|
+
Send notification when a job completes successfully.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
job: Job dictionary with job_id, user_email, artist, title
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Number of notifications sent
|
|
241
|
+
"""
|
|
242
|
+
user_email = job.get("user_email")
|
|
243
|
+
if not user_email:
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
job_id = job.get("job_id", "unknown")
|
|
247
|
+
artist = job.get("artist", "Unknown Artist")
|
|
248
|
+
title = job.get("title", "Unknown Title")
|
|
249
|
+
|
|
250
|
+
return await self.send_push(
|
|
251
|
+
user_email=user_email,
|
|
252
|
+
title="Video Ready!",
|
|
253
|
+
body=f'Your karaoke video for "{title}" by {artist} is ready to download',
|
|
254
|
+
url=f"/app/?job={job_id}",
|
|
255
|
+
tag=f"complete-{job_id}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def add_subscription(
|
|
259
|
+
self,
|
|
260
|
+
user_email: str,
|
|
261
|
+
endpoint: str,
|
|
262
|
+
keys: dict,
|
|
263
|
+
device_name: Optional[str] = None
|
|
264
|
+
) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Add a push subscription for a user.
|
|
267
|
+
|
|
268
|
+
Enforces max subscriptions per user - oldest removed if limit exceeded.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
user_email: User's email address
|
|
272
|
+
endpoint: Push service endpoint URL
|
|
273
|
+
keys: Encryption keys (p256dh, auth)
|
|
274
|
+
device_name: Optional device identifier
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
True if subscription was added successfully
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
user_ref = self.db.collection("gen_users").document(user_email)
|
|
281
|
+
user_doc = user_ref.get()
|
|
282
|
+
|
|
283
|
+
if not user_doc.exists:
|
|
284
|
+
logger.warning(f"User {user_email} not found")
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
user_data = user_doc.to_dict()
|
|
288
|
+
subscriptions = user_data.get("push_subscriptions", [])
|
|
289
|
+
|
|
290
|
+
# Check if this endpoint already exists (update it)
|
|
291
|
+
existing_idx = None
|
|
292
|
+
for i, sub in enumerate(subscriptions):
|
|
293
|
+
if sub["endpoint"] == endpoint:
|
|
294
|
+
existing_idx = i
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
new_sub = {
|
|
298
|
+
"endpoint": endpoint,
|
|
299
|
+
"keys": keys,
|
|
300
|
+
"device_name": device_name,
|
|
301
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
302
|
+
"last_used_at": None
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if existing_idx is not None:
|
|
306
|
+
# Update existing subscription
|
|
307
|
+
subscriptions[existing_idx] = new_sub
|
|
308
|
+
logger.info(f"Updated existing push subscription for {user_email}")
|
|
309
|
+
else:
|
|
310
|
+
# Add new subscription
|
|
311
|
+
subscriptions.append(new_sub)
|
|
312
|
+
logger.info(f"Added new push subscription for {user_email}")
|
|
313
|
+
|
|
314
|
+
# Enforce max subscriptions (remove oldest)
|
|
315
|
+
max_subs = self.settings.max_push_subscriptions_per_user
|
|
316
|
+
if len(subscriptions) > max_subs:
|
|
317
|
+
# Sort by created_at and keep only the newest
|
|
318
|
+
subscriptions.sort(key=lambda s: s.get("created_at", ""), reverse=True)
|
|
319
|
+
removed = subscriptions[max_subs:]
|
|
320
|
+
subscriptions = subscriptions[:max_subs]
|
|
321
|
+
logger.info(f"Removed {len(removed)} old subscriptions for {user_email} (max {max_subs})")
|
|
322
|
+
|
|
323
|
+
user_ref.update({"push_subscriptions": subscriptions})
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.error(f"Failed to add subscription for {user_email}: {e}")
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
async def remove_subscription(self, user_email: str, endpoint: str) -> bool:
|
|
331
|
+
"""
|
|
332
|
+
Remove a push subscription for a user.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
user_email: User's email address
|
|
336
|
+
endpoint: Push service endpoint URL to remove
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
True if subscription was removed
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
user_ref = self.db.collection("gen_users").document(user_email)
|
|
343
|
+
user_doc = user_ref.get()
|
|
344
|
+
|
|
345
|
+
if not user_doc.exists:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
user_data = user_doc.to_dict()
|
|
349
|
+
subscriptions = user_data.get("push_subscriptions", [])
|
|
350
|
+
|
|
351
|
+
# Filter out the subscription
|
|
352
|
+
new_subs = [s for s in subscriptions if s["endpoint"] != endpoint]
|
|
353
|
+
|
|
354
|
+
if len(new_subs) < len(subscriptions):
|
|
355
|
+
user_ref.update({"push_subscriptions": new_subs})
|
|
356
|
+
logger.info(f"Removed push subscription for {user_email}")
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.error(f"Failed to remove subscription for {user_email}: {e}")
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
async def list_subscriptions(self, user_email: str) -> List[dict]:
|
|
366
|
+
"""
|
|
367
|
+
List all push subscriptions for a user.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
user_email: User's email address
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
List of subscription info (endpoint, device_name, created_at, last_used_at)
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
user_doc = self.db.collection("gen_users").document(user_email).get()
|
|
377
|
+
|
|
378
|
+
if not user_doc.exists:
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
user_data = user_doc.to_dict()
|
|
382
|
+
subscriptions = user_data.get("push_subscriptions", [])
|
|
383
|
+
|
|
384
|
+
# Return safe subset of subscription data
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
"endpoint": s["endpoint"],
|
|
388
|
+
"device_name": s.get("device_name"),
|
|
389
|
+
"created_at": s.get("created_at"),
|
|
390
|
+
"last_used_at": s.get("last_used_at")
|
|
391
|
+
}
|
|
392
|
+
for s in subscriptions
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
logger.error(f"Failed to list subscriptions for {user_email}: {e}")
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Singleton instance
|
|
401
|
+
_push_service: Optional[PushNotificationService] = None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_push_notification_service(db=None) -> PushNotificationService:
|
|
405
|
+
"""Get the push notification service singleton."""
|
|
406
|
+
global _push_service
|
|
407
|
+
if _push_service is None:
|
|
408
|
+
_push_service = PushNotificationService(db)
|
|
409
|
+
return _push_service
|
|
@@ -202,9 +202,9 @@ class StripeService:
|
|
|
202
202
|
return False, None, "Payment processing is not configured"
|
|
203
203
|
|
|
204
204
|
try:
|
|
205
|
-
# Default URLs - redirect to
|
|
205
|
+
# Default URLs - redirect to gen app success page
|
|
206
206
|
if not success_url:
|
|
207
|
-
success_url = "
|
|
207
|
+
success_url = f"{self.frontend_url}/order/success?session_id={{CHECKOUT_SESSION_ID}}"
|
|
208
208
|
if not cancel_url:
|
|
209
209
|
cancel_url = "https://nomadkaraoke.com/#do-it-for-me"
|
|
210
210
|
|
backend/tests/conftest.py
CHANGED
|
@@ -93,7 +93,8 @@ def mock_auth_dependency(request):
|
|
|
93
93
|
return
|
|
94
94
|
|
|
95
95
|
# Skip for service-only unit tests that don't need the FastAPI app
|
|
96
|
-
|
|
96
|
+
# Also skip tests that manage their own auth mocks (e.g., push tests use AuthResult objects)
|
|
97
|
+
service_only_tests = ['test_rate_limit_service', 'test_email_validation_service', 'test_rate_limits_api', 'test_gce_encoding_worker', 'test_push_routes', 'test_push_notification_service']
|
|
97
98
|
if any(test_name in test_path for test_name in service_only_tests):
|
|
98
99
|
yield
|
|
99
100
|
return
|