karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__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.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,8 +8,28 @@ Handles:
8
8
  - Stripe checkout and webhooks
9
9
  - Admin user management
10
10
  """
11
+ import hashlib
11
12
  import logging
12
13
  from typing import Optional, Tuple
14
+
15
+
16
+ def _mask_email(email: str) -> str:
17
+ """Mask email for logging to avoid PII exposure.
18
+
19
+ Example: test@example.com -> te***@ex***.com
20
+ """
21
+ if not email or "@" not in email:
22
+ return "***"
23
+ local, domain = email.split("@", 1)
24
+ masked_local = local[:2] + "***" if len(local) > 2 else "***"
25
+ domain_parts = domain.split(".")
26
+ if len(domain_parts) >= 2:
27
+ masked_domain = domain_parts[0][:2] + "***." + domain_parts[-1]
28
+ else:
29
+ masked_domain = "***"
30
+ return f"{masked_local}@{masked_domain}"
31
+
32
+
13
33
  from fastapi import APIRouter, HTTPException, Depends, Request, Header
14
34
  from pydantic import BaseModel, EmailStr
15
35
 
@@ -32,8 +52,11 @@ from backend.models.user import (
32
52
  from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
33
53
  from backend.services.email_service import get_email_service, EmailService
34
54
  from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
55
+ from backend.services.theme_service import get_theme_service
35
56
  from backend.api.dependencies import require_admin
57
+ from backend.api.routes.file_upload import _prepare_theme_for_job
36
58
  from backend.services.auth_service import UserType
59
+ from backend.utils.test_data import is_test_email
37
60
 
38
61
 
39
62
  logger = logging.getLogger(__name__)
@@ -57,6 +80,16 @@ class CreateCheckoutResponse(BaseModel):
57
80
  message: str
58
81
 
59
82
 
83
+ class MadeForYouCheckoutRequest(BaseModel):
84
+ """Request to create a made-for-you karaoke video order."""
85
+ email: EmailStr
86
+ artist: str
87
+ title: str
88
+ source_type: str = "search" # search, youtube, or upload
89
+ youtube_url: Optional[str] = None
90
+ notes: Optional[str] = None
91
+
92
+
60
93
  class CreditPackage(BaseModel):
61
94
  """Credit package information."""
62
95
  id: str
@@ -99,7 +132,11 @@ async def send_magic_link(
99
132
 
100
133
  The user will receive an email with a link that logs them in.
101
134
  Links expire after 15 minutes and can only be used once.
135
+
136
+ For white-label tenants, validates email domain against tenant config.
102
137
  """
138
+ from backend.middleware.tenant import get_tenant_from_request, get_tenant_config_from_request
139
+
103
140
  # Check if email service is configured
104
141
  if not email_service.is_configured():
105
142
  logger.error("Email service not configured - cannot send magic links")
@@ -110,19 +147,39 @@ async def send_magic_link(
110
147
 
111
148
  email = request.email.lower()
112
149
 
150
+ # Get tenant context from middleware
151
+ tenant_id = get_tenant_from_request(http_request)
152
+ tenant_config = get_tenant_config_from_request(http_request)
153
+
154
+ # Validate email domain for tenant if configured
155
+ if tenant_config and tenant_config.auth.allowed_email_domains:
156
+ if not tenant_config.is_email_allowed(email):
157
+ logger.warning(f"Email domain not allowed for tenant {tenant_id}: {_mask_email(email)}")
158
+ # Return success anyway to prevent email enumeration
159
+ return SendMagicLinkResponse(
160
+ status="success",
161
+ message="If this email is registered, you will receive a sign-in link shortly."
162
+ )
163
+
113
164
  # Get client info for security logging
114
165
  ip_address = http_request.client.host if http_request.client else None
115
166
  user_agent = http_request.headers.get("user-agent")
116
167
 
117
- # Create magic link token
168
+ # Create magic link token with tenant context
118
169
  magic_link = user_service.create_magic_link(
119
170
  email,
120
171
  ip_address=ip_address,
121
- user_agent=user_agent
172
+ user_agent=user_agent,
173
+ tenant_id=tenant_id
122
174
  )
123
175
 
124
- # Send email
125
- sent = email_service.send_magic_link(email, magic_link.token)
176
+ # Get tenant-specific sender email
177
+ sender_email = None
178
+ if tenant_config:
179
+ sender_email = tenant_config.get_sender_email()
180
+
181
+ # Send email (with tenant-specific sender if configured)
182
+ sent = email_service.send_magic_link(email, magic_link.token, sender_email=sender_email)
126
183
 
127
184
  if not sent:
128
185
  logger.error(f"Failed to send magic link email to {email}")
@@ -146,18 +203,26 @@ async def verify_magic_link(
146
203
  Verify a magic link token and create a session.
147
204
 
148
205
  Returns a session token that should be stored and used for subsequent requests.
206
+ The session will be associated with the tenant from the magic link.
149
207
  """
208
+ # Reject empty tokens early to avoid invalid Firestore document paths
209
+ if not token or not token.strip():
210
+ raise HTTPException(status_code=401, detail="Invalid token")
211
+
150
212
  # Get client info
151
213
  ip_address = http_request.client.host if http_request.client else None
152
214
  user_agent = http_request.headers.get("user-agent")
153
215
 
154
216
  # Check if this is a first login BEFORE verification (which sets last_login_at)
155
217
  # We need to get the user's state before verify_magic_link updates it
218
+ # Also extract tenant_id from the magic link for session creation
156
219
  from backend.services.user_service import MAGIC_LINKS_COLLECTION
157
220
  magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
158
221
  is_first_login = False
222
+ magic_link_tenant_id = None
159
223
  if magic_link_doc.exists:
160
224
  magic_link_data = magic_link_doc.to_dict()
225
+ magic_link_tenant_id = magic_link_data.get('tenant_id')
161
226
  pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
162
227
  if pre_verify_user:
163
228
  is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
@@ -168,18 +233,19 @@ async def verify_magic_link(
168
233
  if not success or not user:
169
234
  raise HTTPException(status_code=401, detail=message)
170
235
 
171
- # Create session
236
+ # Create session with tenant context from the magic link
172
237
  session = user_service.create_session(
173
238
  user.email,
174
239
  ip_address=ip_address,
175
- user_agent=user_agent
240
+ user_agent=user_agent,
241
+ tenant_id=magic_link_tenant_id
176
242
  )
177
243
 
178
244
  # Send welcome email to first-time users
179
245
  if is_first_login:
180
246
  email_service.send_welcome_email(user.email, user.credits)
181
247
 
182
- # Return user info
248
+ # Return user info with tenant_id
183
249
  user_public = UserPublic(
184
250
  email=user.email,
185
251
  role=user.role,
@@ -187,6 +253,7 @@ async def verify_magic_link(
187
253
  display_name=user.display_name,
188
254
  total_jobs_created=user.total_jobs_created,
189
255
  total_jobs_completed=user.total_jobs_completed,
256
+ tenant_id=user.tenant_id,
190
257
  )
191
258
 
192
259
  return VerifyMagicLinkResponse(
@@ -327,10 +394,327 @@ async def create_checkout(
327
394
  )
328
395
 
329
396
 
397
+ @router.post("/made-for-you/checkout", response_model=CreateCheckoutResponse)
398
+ async def create_made_for_you_checkout(
399
+ request: MadeForYouCheckoutRequest,
400
+ stripe_service: StripeService = Depends(get_stripe_service),
401
+ ):
402
+ """
403
+ Create a Stripe checkout session for a made-for-you karaoke video order.
404
+
405
+ This is the full-service option where Nomad Karaoke handles everything:
406
+ - Finding or processing the audio
407
+ - Reviewing and correcting lyrics
408
+ - Selecting the best instrumental
409
+ - Generating the final video
410
+
411
+ $15 with 24-hour delivery guarantee.
412
+ No authentication required - customer email is provided in the request.
413
+ """
414
+ if not stripe_service.is_configured():
415
+ raise HTTPException(status_code=503, detail="Payment processing is not available")
416
+
417
+ success, checkout_url, message = stripe_service.create_made_for_you_checkout_session(
418
+ customer_email=request.email,
419
+ artist=request.artist,
420
+ title=request.title,
421
+ source_type=request.source_type,
422
+ youtube_url=request.youtube_url,
423
+ notes=request.notes,
424
+ )
425
+
426
+ if not success or not checkout_url:
427
+ raise HTTPException(status_code=400, detail=message)
428
+
429
+ return CreateCheckoutResponse(
430
+ status="success",
431
+ checkout_url=checkout_url,
432
+ message=message,
433
+ )
434
+
435
+
330
436
  # =============================================================================
331
437
  # Stripe Webhooks
332
438
  # =============================================================================
333
439
 
440
+ # Admin email for made-for-you order notifications
441
+ ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
442
+
443
+
444
+ async def _handle_made_for_you_order(
445
+ session_id: str,
446
+ metadata: dict,
447
+ user_service: UserService,
448
+ email_service: EmailService,
449
+ ) -> None:
450
+ """
451
+ Handle a completed made-for-you order by creating a job and notifying Andrew.
452
+
453
+ The made-for-you flow:
454
+ 1. Job is created with made_for_you=True, owned by admin during processing
455
+ 2. For search orders: audio search runs, results stored, job pauses at AWAITING_AUDIO_SELECTION
456
+ 3. Admin receives notification email with link to select audio source
457
+ 4. Customer receives order confirmation email
458
+ 5. Admin selects audio in UI, job proceeds through normal pipeline
459
+ 6. On completion, ownership transfers to customer
460
+
461
+ For orders with a YouTube URL, workers are triggered immediately (no search needed).
462
+
463
+ Args:
464
+ session_id: Stripe checkout session ID
465
+ metadata: Order metadata from Stripe session
466
+ user_service: User service for marking session processed
467
+ email_service: Email service for notifications
468
+ """
469
+ from backend.models.job import JobCreate, JobStatus
470
+ from backend.services.job_manager import JobManager
471
+ from backend.services.worker_service import get_worker_service
472
+ from backend.services.audio_search_service import (
473
+ get_audio_search_service,
474
+ NoResultsError,
475
+ AudioSearchError,
476
+ )
477
+ from backend.services.storage_service import StorageService
478
+ from backend.config import get_settings
479
+ import asyncio
480
+ import tempfile
481
+ import os
482
+
483
+ customer_email = metadata.get("customer_email", "")
484
+ artist = metadata.get("artist", "Unknown Artist")
485
+ title = metadata.get("title", "Unknown Title")
486
+ source_type = metadata.get("source_type", "search")
487
+ youtube_url = metadata.get("youtube_url")
488
+ notes = metadata.get("notes", "")
489
+
490
+ logger.info(
491
+ f"Processing made-for-you order: {artist} - {title} for {customer_email} "
492
+ f"(session: {session_id}, source_type: {source_type})"
493
+ )
494
+
495
+ try:
496
+ job_manager = JobManager()
497
+ worker_service = get_worker_service()
498
+ storage_service = StorageService()
499
+ settings = get_settings()
500
+
501
+ # Apply default theme (Nomad) - same as audio_search endpoint
502
+ theme_service = get_theme_service()
503
+ effective_theme_id = theme_service.get_default_theme_id()
504
+ if effective_theme_id:
505
+ logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
506
+
507
+ # Get distribution defaults from settings (same as audio_search endpoint)
508
+ effective_dropbox_path = settings.default_dropbox_path
509
+ effective_gdrive_folder_id = settings.default_gdrive_folder_id
510
+ effective_enable_youtube_upload = settings.default_enable_youtube_upload
511
+ effective_brand_prefix = settings.default_brand_prefix
512
+ effective_discord_webhook_url = settings.default_discord_webhook_url
513
+ effective_youtube_description = settings.default_youtube_description
514
+
515
+ # Create job with admin ownership during processing
516
+ # CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
517
+ # auto_download=False to pause at audio selection for admin to choose
518
+ job_create = JobCreate(
519
+ url=youtube_url if youtube_url else None,
520
+ artist=artist,
521
+ title=title,
522
+ user_email=ADMIN_EMAIL, # Admin owns during processing
523
+ theme_id=effective_theme_id, # Apply default theme
524
+ non_interactive=False, # Admin will review lyrics/instrumental
525
+ # Made-for-you specific fields
526
+ made_for_you=True, # Flag for ownership transfer on completion
527
+ customer_email=customer_email, # Customer email for final delivery
528
+ customer_notes=notes if notes else None, # Customer's special requests
529
+ # Audio search fields for search-based orders
530
+ audio_search_artist=artist if not youtube_url else None,
531
+ audio_search_title=title if not youtube_url else None,
532
+ auto_download=False, # Pause at audio selection for admin to choose
533
+ # Distribution settings from server defaults
534
+ enable_youtube_upload=effective_enable_youtube_upload,
535
+ dropbox_path=effective_dropbox_path,
536
+ gdrive_folder_id=effective_gdrive_folder_id,
537
+ brand_prefix=effective_brand_prefix,
538
+ discord_webhook_url=effective_discord_webhook_url,
539
+ youtube_description=effective_youtube_description,
540
+ )
541
+ job = job_manager.create_job(job_create)
542
+ job_id = job.job_id
543
+
544
+ logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
545
+
546
+ # Prepare theme style assets for the job (same as audio_search endpoint)
547
+ if effective_theme_id:
548
+ try:
549
+ style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
550
+ job_id, effective_theme_id, None # No color overrides for made-for-you
551
+ )
552
+ theme_update = {
553
+ 'style_params_gcs_path': style_params_path,
554
+ 'style_assets': theme_style_assets,
555
+ }
556
+ if youtube_desc:
557
+ theme_update['youtube_description_template'] = youtube_desc
558
+ job_manager.update_job(job_id, theme_update)
559
+ logger.info(f"Applied theme '{effective_theme_id}' to made-for-you job {job_id}")
560
+ except Exception as e:
561
+ logger.warning(f"Failed to prepare theme for made-for-you job {job_id}: {e}")
562
+
563
+ # Mark session as processed for idempotency
564
+ # Note: Using internal method since this isn't a credit transaction
565
+ user_service._mark_stripe_session_processed(
566
+ stripe_session_id=session_id,
567
+ email=customer_email,
568
+ amount=0 # No credits, just tracking the session
569
+ )
570
+
571
+ # Initialize search_results for later use in email notification
572
+ search_results = None
573
+
574
+ # Handle based on whether we have a YouTube URL or need to search
575
+ if youtube_url:
576
+ # URL provided - trigger workers directly (no audio selection needed)
577
+ logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
578
+ await asyncio.gather(
579
+ worker_service.trigger_audio_worker(job_id),
580
+ worker_service.trigger_lyrics_worker(job_id)
581
+ )
582
+ else:
583
+ # No URL - use audio search flow, pause for admin selection
584
+ # Made-for-you jobs require admin to select audio source
585
+ logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
586
+
587
+ # Update job with audio search fields
588
+ job_manager.update_job(job_id, {
589
+ 'audio_search_artist': artist,
590
+ 'audio_search_title': title,
591
+ 'auto_download': False, # Admin must select
592
+ })
593
+
594
+ # Transition to searching state
595
+ job_manager.transition_to_state(
596
+ job_id=job_id,
597
+ new_status=JobStatus.SEARCHING_AUDIO,
598
+ progress=5,
599
+ message=f"Searching for audio: {artist} - {title}"
600
+ )
601
+
602
+ # Perform audio search
603
+ audio_search_service = get_audio_search_service()
604
+
605
+ try:
606
+ search_results = audio_search_service.search(artist, title)
607
+ except NoResultsError as e:
608
+ # No results found - still transition to AWAITING_AUDIO_SELECTION
609
+ # Admin can manually provide audio
610
+ logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
611
+ job_manager.transition_to_state(
612
+ job_id=job_id,
613
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
614
+ progress=10,
615
+ message=f"No automatic audio sources found. Manual intervention required."
616
+ )
617
+ search_results = None
618
+ except AudioSearchError as e:
619
+ logger.error(f"Job {job_id}: Audio search failed: {e}")
620
+ job_manager.transition_to_state(
621
+ job_id=job_id,
622
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
623
+ progress=10,
624
+ message=f"Audio search error. Manual intervention required."
625
+ )
626
+ search_results = None
627
+
628
+ if search_results:
629
+ # Store search results in state_data for admin to review
630
+ results_dicts = [r.to_dict() for r in search_results]
631
+ state_data_update = {
632
+ 'audio_search_results': results_dicts,
633
+ 'audio_search_count': len(results_dicts),
634
+ }
635
+ if audio_search_service.last_remote_search_id:
636
+ state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
637
+ job_manager.update_job(job_id, {'state_data': state_data_update})
638
+
639
+ # Transition to AWAITING_AUDIO_SELECTION for admin to choose
640
+ # Do NOT auto-select or download - admin must review and select
641
+ logger.info(f"Job {job_id}: Found {len(results_dicts)} audio sources, awaiting admin selection")
642
+
643
+ # Transition to AWAITING_AUDIO_SELECTION for admin to choose
644
+ # Admin will select from results in the UI, then job proceeds
645
+ job_manager.transition_to_state(
646
+ job_id=job_id,
647
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
648
+ progress=10,
649
+ message=f"Found {len(results_dicts)} audio sources. Awaiting admin selection."
650
+ )
651
+
652
+ # Get audio source count for admin notification
653
+ # (search_results may be set from the search flow above, or None for YouTube URL orders)
654
+ audio_source_count = len(search_results) if search_results else 0
655
+
656
+ # Generate admin login token for one-click email access (24hr expiry)
657
+ admin_login = user_service.create_admin_login_token(
658
+ email=ADMIN_EMAIL,
659
+ expiry_hours=24,
660
+ )
661
+
662
+ # Send confirmation email to customer using professional template
663
+ email_service.send_made_for_you_order_confirmation(
664
+ to_email=customer_email,
665
+ artist=artist,
666
+ title=title,
667
+ job_id=job_id,
668
+ notes=notes,
669
+ )
670
+
671
+ # Send notification email to admin using professional template
672
+ email_service.send_made_for_you_admin_notification(
673
+ to_email=ADMIN_EMAIL,
674
+ customer_email=customer_email,
675
+ artist=artist,
676
+ title=title,
677
+ job_id=job_id,
678
+ admin_login_token=admin_login.token,
679
+ notes=notes,
680
+ audio_source_count=audio_source_count,
681
+ )
682
+
683
+ logger.info(f"Sent made-for-you order notifications for job {job_id}")
684
+
685
+ except Exception as e:
686
+ logger.error(f"Error processing made-for-you order: {e}", exc_info=True)
687
+ # Still try to notify Andrew of the failure
688
+ try:
689
+ email_service.send_email(
690
+ to_email=ADMIN_EMAIL,
691
+ subject=f"[FAILED] Made For You Order: {artist} - {title}",
692
+ html_content=f"""
693
+ <h2>Made-For-You Order Failed</h2>
694
+ <p>An error occurred processing this order:</p>
695
+ <ul>
696
+ <li><strong>Customer:</strong> {customer_email}</li>
697
+ <li><strong>Artist:</strong> {artist}</li>
698
+ <li><strong>Title:</strong> {title}</li>
699
+ <li><strong>Error:</strong> {str(e)}</li>
700
+ </ul>
701
+ <p>Please manually create this job and notify the customer.</p>
702
+ """,
703
+ text_content=f"""
704
+ Made-For-You Order Failed
705
+
706
+ Customer: {customer_email}
707
+ Artist: {artist}
708
+ Title: {title}
709
+ Error: {str(e)}
710
+
711
+ Please manually create this job and notify the customer.
712
+ """.strip(),
713
+ )
714
+ except Exception as email_error:
715
+ logger.error(f"Failed to send error notification: {email_error}")
716
+
717
+
334
718
  @router.post("/webhooks/stripe")
335
719
  async def stripe_webhook(
336
720
  request: Request,
@@ -365,30 +749,41 @@ async def stripe_webhook(
365
749
  if event_type == "checkout.session.completed":
366
750
  session = event["data"]["object"]
367
751
  session_id = session.get("id")
752
+ metadata = session.get("metadata", {})
368
753
 
369
754
  # Idempotency check: Skip if this session was already processed
370
755
  if session_id and user_service.is_stripe_session_processed(session_id):
371
756
  logger.info(f"Skipping already processed session: {session_id}")
372
757
  return {"status": "received", "type": event_type, "note": "already_processed"}
373
758
 
374
- # Process the completed checkout
375
- success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
376
-
377
- if success and user_email and credits > 0:
378
- # Add credits to user account
379
- ok, new_balance, credit_msg = user_service.add_credits(
380
- email=user_email,
381
- amount=credits,
382
- reason="stripe_purchase",
383
- stripe_session_id=session_id,
759
+ # Check if this is a made-for-you order
760
+ if metadata.get("order_type") == "made_for_you":
761
+ # Handle made-for-you order - create a job
762
+ await _handle_made_for_you_order(
763
+ session_id=session_id,
764
+ metadata=metadata,
765
+ user_service=user_service,
766
+ email_service=email_service,
384
767
  )
385
-
386
- if ok:
387
- # Send confirmation email
388
- email_service.send_credits_added(user_email, credits, new_balance)
389
- logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
390
- else:
391
- logger.error(f"Failed to add credits: {credit_msg}")
768
+ else:
769
+ # Handle regular credit purchase
770
+ success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
771
+
772
+ if success and user_email and credits > 0:
773
+ # Add credits to user account
774
+ ok, new_balance, credit_msg = user_service.add_credits(
775
+ email=user_email,
776
+ amount=credits,
777
+ reason="stripe_purchase",
778
+ stripe_session_id=session_id,
779
+ )
780
+
781
+ if ok:
782
+ # Send confirmation email
783
+ email_service.send_credits_added(user_email, credits, new_balance)
784
+ logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
785
+ else:
786
+ logger.error(f"Failed to add credits: {credit_msg}")
392
787
 
393
788
  elif event_type == "checkout.session.expired":
394
789
  logger.info(f"Checkout session expired: {event['data']['object'].get('id')}")
@@ -663,6 +1058,7 @@ async def list_users(
663
1058
  sort_by: str = "created_at",
664
1059
  sort_order: str = "desc",
665
1060
  include_inactive: bool = False,
1061
+ exclude_test: bool = True,
666
1062
  auth_data: Tuple[str, UserType, int] = Depends(require_admin),
667
1063
  user_service: UserService = Depends(get_user_service),
668
1064
  ):
@@ -676,6 +1072,7 @@ async def list_users(
676
1072
  sort_by: Field to sort by (created_at, last_login_at, credits, email)
677
1073
  sort_order: Sort direction (asc, desc)
678
1074
  include_inactive: Include disabled users
1075
+ exclude_test: If True (default), exclude test users (e.g., @inbox.testmail.app)
679
1076
  """
680
1077
  from google.cloud import firestore
681
1078
  from google.cloud.firestore_v1 import FieldFilter
@@ -704,9 +1101,14 @@ async def list_users(
704
1101
  else:
705
1102
  query = query.order_by("created_at", direction=direction)
706
1103
 
707
- # Get total count (without pagination) for has_more calculation
1104
+ # Get all docs and filter in Python
708
1105
  # Note: This is expensive for large datasets, consider caching
709
1106
  all_docs = list(query.stream())
1107
+
1108
+ # Filter out test users if exclude_test is True
1109
+ if exclude_test:
1110
+ all_docs = [d for d in all_docs if not is_test_email(d.to_dict().get('email', ''))]
1111
+
710
1112
  total_count = len(all_docs)
711
1113
 
712
1114
  # Apply pagination manually (Firestore doesn't support offset well)
@@ -942,44 +1344,64 @@ async def list_beta_feedback(
942
1344
 
943
1345
  @router.get("/admin/beta/stats")
944
1346
  async def get_beta_stats(
1347
+ exclude_test: bool = True,
945
1348
  auth_data: Tuple[str, UserType, int] = Depends(require_admin),
946
1349
  user_service: UserService = Depends(get_user_service),
947
1350
  ):
948
1351
  """
949
1352
  Get beta tester program statistics (admin only).
1353
+
1354
+ Args:
1355
+ exclude_test: If True (default), exclude test users from beta stats
950
1356
  """
951
1357
  from google.cloud.firestore_v1 import FieldFilter
952
1358
  from google.cloud.firestore_v1 import aggregation
953
1359
 
954
- # Count beta testers by status using efficient aggregation queries
955
1360
  users_collection = user_service.db.collection(USERS_COLLECTION)
956
1361
 
957
- # Helper function to get count using aggregation
958
- def get_count(query) -> int:
959
- agg_query = aggregation.AggregationQuery(query)
960
- agg_query.count(alias="count")
961
- results = agg_query.get()
962
- return results[0][0].value if results else 0
963
-
964
- total_beta_testers = get_count(
965
- users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
966
- )
967
-
968
- active_testers = get_count(
969
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
970
- )
971
-
972
- pending_feedback = get_count(
973
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
974
- )
1362
+ if exclude_test:
1363
+ # Stream and filter in Python since Firestore doesn't support "not ends with"
1364
+ all_beta_users = []
1365
+ for doc in users_collection.where(filter=FieldFilter("is_beta_tester", "==", True)).stream():
1366
+ data = doc.to_dict()
1367
+ if not is_test_email(data.get("email", "")):
1368
+ all_beta_users.append(data)
975
1369
 
976
- completed_feedback = get_count(
977
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
978
- )
1370
+ total_beta_testers = len(all_beta_users)
1371
+ active_testers = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "active")
1372
+ pending_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "pending_feedback")
1373
+ completed_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "completed")
979
1374
 
980
- # Get average ratings from feedback
981
- feedback_docs = list(user_service.db.collection("beta_feedback").stream())
1375
+ # Filter feedback by non-test users
1376
+ all_feedback = []
1377
+ for doc in user_service.db.collection("beta_feedback").stream():
1378
+ data = doc.to_dict()
1379
+ if not is_test_email(data.get("user_email", "")):
1380
+ all_feedback.append(data)
1381
+ feedback_docs = all_feedback
1382
+ else:
1383
+ # Use efficient aggregation queries when including test data
1384
+ def get_count(query) -> int:
1385
+ agg_query = aggregation.AggregationQuery(query)
1386
+ agg_query.count(alias="count")
1387
+ results = agg_query.get()
1388
+ return results[0][0].value if results else 0
1389
+
1390
+ total_beta_testers = get_count(
1391
+ users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
1392
+ )
1393
+ active_testers = get_count(
1394
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
1395
+ )
1396
+ pending_feedback = get_count(
1397
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
1398
+ )
1399
+ completed_feedback = get_count(
1400
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
1401
+ )
1402
+ feedback_docs = [doc.to_dict() for doc in user_service.db.collection("beta_feedback").stream()]
982
1403
 
1404
+ # Calculate average ratings from feedback
983
1405
  avg_overall = 0
984
1406
  avg_ease = 0
985
1407
  avg_accuracy = 0
@@ -987,8 +1409,7 @@ async def get_beta_stats(
987
1409
 
988
1410
  if feedback_docs:
989
1411
  total = len(feedback_docs)
990
- for doc in feedback_docs:
991
- data = doc.to_dict()
1412
+ for data in feedback_docs:
992
1413
  avg_overall += data.get("overall_rating", 0)
993
1414
  avg_ease += data.get("ease_of_use_rating", 0)
994
1415
  avg_accuracy += data.get("lyrics_accuracy_rating", 0)