karaoke-gen 0.99.3__py3-none-any.whl → 0.103.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.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -46,11 +46,12 @@ CREDIT_PACKAGES = {
46
46
  },
47
47
  }
48
48
 
49
- # Done-for-you service package (not a credit package - creates a job directly)
50
- DONE_FOR_YOU_PACKAGE = {
49
+ # Made-for-you service package (not a credit package - creates a job directly)
50
+ MADE_FOR_YOU_PACKAGE = {
51
51
  "price_cents": 1500, # $15.00
52
- "name": "Done For You Karaoke Video",
53
- "description": "Full-service karaoke video creation with 24-hour delivery",
52
+ "name": "Made For You Karaoke Video",
53
+ "description": "Professional 4K karaoke video with perfectly synced lyrics, delivered to your email within 24 hours",
54
+ "images": ["https://gen.nomadkaraoke.com/nomad-logo.png"],
54
55
  }
55
56
 
56
57
 
@@ -65,6 +66,10 @@ class StripeService:
65
66
  self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
66
67
  # After consolidation, buy URL is the same as frontend URL
67
68
  self.buy_url = os.getenv("BUY_URL", self.frontend_url)
69
+ # Payment method configuration ID from Stripe Dashboard
70
+ # Enables Google Pay, Apple Pay, Link, and other wallet methods
71
+ # Get this from: Dashboard > Settings > Payment methods > [Your Config] > Copy ID
72
+ self.payment_method_config = os.getenv("STRIPE_PAYMENT_METHOD_CONFIG")
68
73
 
69
74
  if self.secret_key:
70
75
  stripe.api_key = self.secret_key
@@ -72,6 +77,11 @@ class StripeService:
72
77
  else:
73
78
  logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
74
79
 
80
+ if self.payment_method_config:
81
+ logger.info(f"Using payment method config: {self.payment_method_config}")
82
+ else:
83
+ logger.info("No STRIPE_PAYMENT_METHOD_CONFIG set - using Stripe defaults")
84
+
75
85
  def is_configured(self) -> bool:
76
86
  """Check if Stripe is properly configured."""
77
87
  return bool(self.secret_key)
@@ -113,32 +123,40 @@ class StripeService:
113
123
  if not cancel_url:
114
124
  cancel_url = f"{self.buy_url}?cancelled=true"
115
125
 
116
- # Create checkout session
117
- session = stripe.checkout.Session.create(
118
- payment_method_types=['card'],
119
- line_items=[{
126
+ # Build checkout session params
127
+ session_params = {
128
+ # Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
129
+ 'line_items': [{
120
130
  'price_data': {
121
131
  'currency': 'usd',
122
132
  'product_data': {
123
133
  'name': package['name'],
124
134
  'description': package['description'],
135
+ 'images': ['https://gen.nomadkaraoke.com/nomad-logo.png'],
125
136
  },
126
137
  'unit_amount': package['price_cents'],
127
138
  },
128
139
  'quantity': 1,
129
140
  }],
130
- mode='payment',
131
- success_url=success_url,
132
- cancel_url=cancel_url,
133
- customer_email=user_email,
134
- metadata={
141
+ 'mode': 'payment',
142
+ 'success_url': success_url,
143
+ 'cancel_url': cancel_url,
144
+ 'customer_email': user_email,
145
+ 'metadata': {
135
146
  'package_id': package_id,
136
147
  'credits': str(package['credits']),
137
148
  'user_email': user_email,
138
149
  },
139
150
  # Allow promotion codes
140
- allow_promotion_codes=True,
141
- )
151
+ 'allow_promotion_codes': True,
152
+ }
153
+
154
+ # Add payment method configuration if set (enables Google Pay, Link, etc.)
155
+ if self.payment_method_config:
156
+ session_params['payment_method_configuration'] = self.payment_method_config
157
+
158
+ # Create checkout session
159
+ session = stripe.checkout.Session.create(**session_params)
142
160
 
143
161
  logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
144
162
  return True, session.url, "Checkout session created"
@@ -150,7 +168,7 @@ class StripeService:
150
168
  logger.error(f"Error creating checkout session: {e}")
151
169
  return False, None, "Failed to create checkout session"
152
170
 
153
- def create_done_for_you_checkout_session(
171
+ def create_made_for_you_checkout_session(
154
172
  self,
155
173
  customer_email: str,
156
174
  artist: str,
@@ -162,7 +180,7 @@ class StripeService:
162
180
  cancel_url: Optional[str] = None,
163
181
  ) -> Tuple[bool, Optional[str], str]:
164
182
  """
165
- Create a Stripe Checkout session for a done-for-you order.
183
+ Create a Stripe Checkout session for a made-for-you order.
166
184
 
167
185
  This is for the full-service karaoke video creation where Nomad Karaoke
168
186
  handles all the work (lyrics review, instrumental selection, etc.).
@@ -192,7 +210,7 @@ class StripeService:
192
210
 
193
211
  # Build metadata for job creation after payment
194
212
  metadata = {
195
- 'order_type': 'done_for_you',
213
+ 'order_type': 'made_for_you',
196
214
  'customer_email': customer_email,
197
215
  'artist': artist,
198
216
  'title': title,
@@ -204,39 +222,47 @@ class StripeService:
204
222
  # Truncate notes to fit Stripe's 500 char limit per metadata value
205
223
  metadata['notes'] = notes[:500] if len(notes) > 500 else notes
206
224
 
207
- # Create checkout session
208
- session = stripe.checkout.Session.create(
209
- payment_method_types=['card'],
210
- line_items=[{
225
+ # Build checkout session params
226
+ session_params = {
227
+ # Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
228
+ 'line_items': [{
211
229
  'price_data': {
212
230
  'currency': 'usd',
213
231
  'product_data': {
214
- 'name': DONE_FOR_YOU_PACKAGE['name'],
215
- 'description': f"{artist} - {title}",
232
+ 'name': f"Karaoke Video: {artist} - {title}",
233
+ 'description': MADE_FOR_YOU_PACKAGE['description'],
234
+ 'images': MADE_FOR_YOU_PACKAGE['images'],
216
235
  },
217
- 'unit_amount': DONE_FOR_YOU_PACKAGE['price_cents'],
236
+ 'unit_amount': MADE_FOR_YOU_PACKAGE['price_cents'],
218
237
  },
219
238
  'quantity': 1,
220
239
  }],
221
- mode='payment',
222
- success_url=success_url,
223
- cancel_url=cancel_url,
224
- customer_email=customer_email,
225
- metadata=metadata,
226
- allow_promotion_codes=True,
227
- )
240
+ 'mode': 'payment',
241
+ 'success_url': success_url,
242
+ 'cancel_url': cancel_url,
243
+ 'customer_email': customer_email,
244
+ 'metadata': metadata,
245
+ 'allow_promotion_codes': True,
246
+ }
247
+
248
+ # Add payment method configuration if set (enables Google Pay, Link, etc.)
249
+ if self.payment_method_config:
250
+ session_params['payment_method_configuration'] = self.payment_method_config
251
+
252
+ # Create checkout session
253
+ session = stripe.checkout.Session.create(**session_params)
228
254
 
229
255
  logger.info(
230
- f"Created done-for-you checkout session {session.id} for {customer_email}, "
256
+ f"Created made-for-you checkout session {session.id} for {customer_email}, "
231
257
  f"song: {artist} - {title}"
232
258
  )
233
259
  return True, session.url, "Checkout session created"
234
260
 
235
261
  except stripe.error.StripeError as e:
236
- logger.error(f"Stripe error creating done-for-you checkout session: {e}")
262
+ logger.error(f"Stripe error creating made-for-you checkout session: {e}")
237
263
  return False, None, f"Payment error: {str(e)}"
238
264
  except Exception as e:
239
- logger.error(f"Error creating done-for-you checkout session: {e}")
265
+ logger.error(f"Error creating made-for-you checkout session: {e}")
240
266
  return False, None, "Failed to create checkout session"
241
267
 
242
268
  def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
@@ -0,0 +1,285 @@
1
+ """
2
+ Tenant service for managing white-label B2B portal configurations.
3
+
4
+ Tenant configs are stored in GCS at tenants/{tenant_id}/config.json.
5
+ The service provides:
6
+ - Tenant config loading with caching
7
+ - Subdomain to tenant ID resolution
8
+ - Tenant validation
9
+
10
+ Similar to ThemeService, configs are cached for 5 minutes to reduce GCS reads.
11
+ """
12
+
13
+ import logging
14
+ import threading
15
+ from datetime import datetime
16
+ from typing import Dict, Optional
17
+
18
+ from backend.models.tenant import TenantConfig, TenantPublicConfig
19
+ from backend.services.storage_service import StorageService
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # GCS paths for tenant configs
24
+ TENANTS_PREFIX = "tenants"
25
+
26
+ # Default sender email (shared with EmailService for consistency)
27
+ DEFAULT_SENDER_EMAIL = "gen@nomadkaraoke.com"
28
+
29
+
30
+ class TenantService:
31
+ """Service for managing tenant configurations from GCS."""
32
+
33
+ def __init__(self, storage: Optional[StorageService] = None):
34
+ """
35
+ Initialize the tenant service.
36
+
37
+ Args:
38
+ storage: StorageService instance (creates new one if not provided)
39
+ """
40
+ self.storage = storage or StorageService()
41
+ self._config_cache: Dict[str, TenantConfig] = {}
42
+ self._cache_times: Dict[str, datetime] = {}
43
+ self._subdomain_map: Dict[str, str] = {} # subdomain -> tenant_id
44
+ self._subdomain_map_time: Optional[datetime] = None
45
+ self.CACHE_TTL_SECONDS = 300 # 5 minute cache
46
+
47
+ def _get_config_path(self, tenant_id: str) -> str:
48
+ """Get the GCS path for a tenant's config file."""
49
+ return f"{TENANTS_PREFIX}/{tenant_id}/config.json"
50
+
51
+ def _is_cache_valid(self, tenant_id: str) -> bool:
52
+ """Check if the config cache for a tenant is still valid."""
53
+ if tenant_id not in self._config_cache or tenant_id not in self._cache_times:
54
+ return False
55
+ age = datetime.now() - self._cache_times[tenant_id]
56
+ return age.total_seconds() < self.CACHE_TTL_SECONDS
57
+
58
+ def _is_subdomain_map_valid(self) -> bool:
59
+ """Check if the subdomain map cache is still valid."""
60
+ if not self._subdomain_map or self._subdomain_map_time is None:
61
+ return False
62
+ age = datetime.now() - self._subdomain_map_time
63
+ return age.total_seconds() < self.CACHE_TTL_SECONDS
64
+
65
+ def get_tenant_config(
66
+ self, tenant_id: str, force_refresh: bool = False
67
+ ) -> Optional[TenantConfig]:
68
+ """
69
+ Load tenant configuration from GCS with caching.
70
+
71
+ Args:
72
+ tenant_id: The tenant identifier
73
+ force_refresh: Force reload from GCS even if cache is valid
74
+
75
+ Returns:
76
+ TenantConfig if found, None otherwise
77
+ """
78
+ if not force_refresh and self._is_cache_valid(tenant_id):
79
+ return self._config_cache[tenant_id]
80
+
81
+ try:
82
+ config_path = self._get_config_path(tenant_id)
83
+ if not self.storage.file_exists(config_path):
84
+ logger.debug(f"Tenant config not found: {tenant_id}")
85
+ return None
86
+
87
+ data = self.storage.download_json(config_path)
88
+ config = TenantConfig(**data)
89
+
90
+ # Update cache
91
+ self._config_cache[tenant_id] = config
92
+ self._cache_times[tenant_id] = datetime.now()
93
+
94
+ # Update subdomain map and its timestamp
95
+ self._subdomain_map[config.subdomain.lower()] = tenant_id
96
+ self._subdomain_map_time = datetime.now()
97
+
98
+ logger.info(f"Loaded tenant config: {tenant_id}")
99
+ return config
100
+ except Exception as e:
101
+ logger.error(f"Failed to load tenant config {tenant_id}: {e}")
102
+ return None
103
+
104
+ def get_tenant_by_subdomain(self, subdomain: str) -> Optional[TenantConfig]:
105
+ """
106
+ Get tenant config by subdomain.
107
+
108
+ Args:
109
+ subdomain: Full subdomain (e.g., 'vocalstar.nomadkaraoke.com')
110
+
111
+ Returns:
112
+ TenantConfig if found, None otherwise
113
+ """
114
+ subdomain_lower = subdomain.lower()
115
+
116
+ # Check cache first
117
+ if subdomain_lower in self._subdomain_map:
118
+ tenant_id = self._subdomain_map[subdomain_lower]
119
+ return self.get_tenant_config(tenant_id)
120
+
121
+ # Try to find tenant by listing all configs
122
+ # This is a fallback for when we don't have the subdomain cached
123
+ tenant_id = self._resolve_subdomain_to_tenant_id(subdomain_lower)
124
+ if tenant_id:
125
+ return self.get_tenant_config(tenant_id)
126
+
127
+ return None
128
+
129
+ def _resolve_subdomain_to_tenant_id(self, subdomain: str) -> Optional[str]:
130
+ """
131
+ Resolve a subdomain to tenant ID by checking GCS.
132
+
133
+ This extracts the tenant ID from the subdomain pattern:
134
+ - 'vocalstar.nomadkaraoke.com' -> 'vocalstar'
135
+ - 'vocalstar.gen.nomadkaraoke.com' -> 'vocalstar'
136
+ """
137
+ # Extract potential tenant ID from subdomain
138
+ # Pattern: {tenant}.nomadkaraoke.com or {tenant}.gen.nomadkaraoke.com
139
+ parts = subdomain.split(".")
140
+
141
+ if len(parts) >= 3:
142
+ # First part is likely the tenant ID
143
+ potential_tenant_id = parts[0]
144
+
145
+ # Check if config exists
146
+ config_path = self._get_config_path(potential_tenant_id)
147
+ if self.storage.file_exists(config_path):
148
+ return potential_tenant_id
149
+
150
+ return None
151
+
152
+ def get_public_config(self, tenant_id: str) -> Optional[TenantPublicConfig]:
153
+ """
154
+ Get the public (frontend-safe) tenant configuration.
155
+
156
+ Args:
157
+ tenant_id: The tenant identifier
158
+
159
+ Returns:
160
+ TenantPublicConfig if found, None otherwise
161
+ """
162
+ config = self.get_tenant_config(tenant_id)
163
+ if config:
164
+ return TenantPublicConfig.from_config(config)
165
+ return None
166
+
167
+ def get_public_config_by_subdomain(
168
+ self, subdomain: str
169
+ ) -> Optional[TenantPublicConfig]:
170
+ """
171
+ Get the public tenant configuration by subdomain.
172
+
173
+ Args:
174
+ subdomain: Full subdomain
175
+
176
+ Returns:
177
+ TenantPublicConfig if found, None otherwise
178
+ """
179
+ config = self.get_tenant_by_subdomain(subdomain)
180
+ if config:
181
+ return TenantPublicConfig.from_config(config)
182
+ return None
183
+
184
+ def tenant_exists(self, tenant_id: str) -> bool:
185
+ """
186
+ Check if a tenant exists.
187
+
188
+ Args:
189
+ tenant_id: The tenant identifier
190
+
191
+ Returns:
192
+ True if tenant config exists, False otherwise
193
+ """
194
+ config_path = self._get_config_path(tenant_id)
195
+ return self.storage.file_exists(config_path)
196
+
197
+ def is_email_allowed_for_tenant(self, tenant_id: str, email: str) -> bool:
198
+ """
199
+ Check if an email is allowed for a specific tenant.
200
+
201
+ Args:
202
+ tenant_id: The tenant identifier
203
+ email: Email address to check
204
+
205
+ Returns:
206
+ True if email is allowed, False otherwise
207
+ """
208
+ config = self.get_tenant_config(tenant_id)
209
+ if not config:
210
+ return False
211
+ return config.is_email_allowed(email)
212
+
213
+ def get_tenant_sender_email(self, tenant_id: str) -> str:
214
+ """
215
+ Get the email sender address for a tenant.
216
+
217
+ Args:
218
+ tenant_id: The tenant identifier
219
+
220
+ Returns:
221
+ Sender email address
222
+ """
223
+ config = self.get_tenant_config(tenant_id)
224
+ if config:
225
+ return config.get_sender_email()
226
+ # Default fallback (consistent with EmailService)
227
+ return DEFAULT_SENDER_EMAIL
228
+
229
+ def invalidate_cache(self, tenant_id: Optional[str] = None) -> None:
230
+ """
231
+ Invalidate tenant config cache.
232
+
233
+ Args:
234
+ tenant_id: Specific tenant to invalidate, or None for all
235
+ """
236
+ if tenant_id:
237
+ self._config_cache.pop(tenant_id, None)
238
+ self._cache_times.pop(tenant_id, None)
239
+ # Also remove any subdomain map entries pointing to this tenant
240
+ subdomains_to_remove = [
241
+ subdomain
242
+ for subdomain, tid in self._subdomain_map.items()
243
+ if tid == tenant_id
244
+ ]
245
+ for subdomain in subdomains_to_remove:
246
+ self._subdomain_map.pop(subdomain, None)
247
+ logger.info(f"Tenant config cache invalidated: {tenant_id}")
248
+ else:
249
+ self._config_cache.clear()
250
+ self._cache_times.clear()
251
+ self._subdomain_map.clear()
252
+ self._subdomain_map_time = None
253
+ logger.info("All tenant config caches invalidated")
254
+
255
+ def get_asset_url(self, tenant_id: str, asset_name: str) -> Optional[str]:
256
+ """
257
+ Get a signed URL for a tenant asset (logo, favicon, etc.).
258
+
259
+ Args:
260
+ tenant_id: The tenant identifier
261
+ asset_name: Asset filename (e.g., 'logo.png')
262
+
263
+ Returns:
264
+ Signed URL if asset exists, None otherwise
265
+ """
266
+ asset_path = f"{TENANTS_PREFIX}/{tenant_id}/{asset_name}"
267
+ if self.storage.file_exists(asset_path):
268
+ return self.storage.generate_signed_url(asset_path, expiration_minutes=60)
269
+ return None
270
+
271
+
272
+ # Singleton instance with thread-safe initialization
273
+ _tenant_service: Optional[TenantService] = None
274
+ _tenant_service_lock = threading.Lock()
275
+
276
+
277
+ def get_tenant_service() -> TenantService:
278
+ """Get or create the singleton TenantService instance (thread-safe)."""
279
+ global _tenant_service
280
+ if _tenant_service is None:
281
+ with _tenant_service_lock:
282
+ # Double-check after acquiring lock
283
+ if _tenant_service is None:
284
+ _tenant_service = TenantService()
285
+ return _tenant_service
@@ -73,16 +73,26 @@ class UserService:
73
73
  logger.exception(f"Error getting user {email}")
74
74
  return None
75
75
 
76
- def get_or_create_user(self, email: str) -> User:
76
+ def get_or_create_user(self, email: str, tenant_id: Optional[str] = None) -> User:
77
77
  """
78
78
  Get existing user or create a new one.
79
79
 
80
80
  New users receive a welcome credit to try the service.
81
+
82
+ Args:
83
+ email: User's email address
84
+ tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
85
+
86
+ Note: If user exists but has a different tenant_id, the existing user is returned.
87
+ Users are uniquely identified by email, not email+tenant.
81
88
  """
82
89
  email = email.lower()
83
90
  user = self.get_user(email)
84
91
 
85
92
  if user:
93
+ # If user exists but tenant_id differs, we still return the user
94
+ # This allows users to access multiple tenants with one account
95
+ # (though features may be restricted per-tenant)
86
96
  return user
87
97
 
88
98
  # Create new user with welcome credit
@@ -97,9 +107,10 @@ class UserService:
97
107
  email=email,
98
108
  credits=welcome_credit,
99
109
  credit_transactions=[welcome_transaction],
110
+ tenant_id=tenant_id, # Associate with tenant on creation
100
111
  )
101
112
  self._save_user(user)
102
- logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s)")
113
+ logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s) (tenant: {tenant_id or 'default'})")
103
114
  return user
104
115
 
105
116
  def _save_user(self, user: User) -> None:
@@ -153,6 +164,7 @@ class UserService:
153
164
  display_name=user.display_name,
154
165
  total_jobs_created=user.total_jobs_created,
155
166
  total_jobs_completed=user.total_jobs_completed,
167
+ tenant_id=user.tenant_id,
156
168
  )
157
169
 
158
170
  # =========================================================================
@@ -163,18 +175,25 @@ class UserService:
163
175
  self,
164
176
  email: str,
165
177
  ip_address: Optional[str] = None,
166
- user_agent: Optional[str] = None
178
+ user_agent: Optional[str] = None,
179
+ tenant_id: Optional[str] = None
167
180
  ) -> MagicLinkToken:
168
181
  """
169
182
  Create a magic link token for email authentication.
170
183
 
171
184
  Returns the token object. The actual sending of the email
172
185
  should be handled by the caller (or an email service).
186
+
187
+ Args:
188
+ email: User's email address
189
+ ip_address: Client IP for auditing
190
+ user_agent: Client user agent for auditing
191
+ tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
173
192
  """
174
193
  email = email.lower()
175
194
 
176
- # Ensure user exists
177
- self.get_or_create_user(email)
195
+ # Ensure user exists (with tenant association)
196
+ self.get_or_create_user(email, tenant_id=tenant_id)
178
197
 
179
198
  # Generate secure token
180
199
  token = secrets.token_urlsafe(32)
@@ -185,6 +204,7 @@ class UserService:
185
204
  expires_at=datetime.utcnow() + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES),
186
205
  ip_address=ip_address,
187
206
  user_agent=user_agent,
207
+ tenant_id=tenant_id,
188
208
  )
189
209
 
190
210
  # Save to Firestore
@@ -194,6 +214,54 @@ class UserService:
194
214
  logger.info(f"Created magic link for {email}")
195
215
  return magic_link
196
216
 
217
+ def create_admin_login_token(
218
+ self,
219
+ email: str,
220
+ expiry_hours: int = 24,
221
+ ) -> MagicLinkToken:
222
+ """
223
+ Create an admin login token for email-embedded authentication links.
224
+
225
+ Similar to magic links but with configurable expiry (default 24 hours).
226
+ Used for made-for-you order notification emails to allow admin one-click login.
227
+
228
+ Args:
229
+ email: Admin's email address to authenticate as
230
+ expiry_hours: Hours until token expires (default: 24, max: 168)
231
+
232
+ Returns:
233
+ MagicLinkToken object containing the token
234
+
235
+ Raises:
236
+ ValueError: If expiry_hours is out of valid range (1-168)
237
+ """
238
+ # Validate expiry_hours (1 hour to 7 days)
239
+ if not 1 <= expiry_hours <= 168:
240
+ raise ValueError(f"expiry_hours must be between 1 and 168, got {expiry_hours}")
241
+
242
+ email = email.lower()
243
+
244
+ # Ensure user exists
245
+ self.get_or_create_user(email)
246
+
247
+ # Generate secure token
248
+ token = secrets.token_urlsafe(32)
249
+
250
+ admin_login = MagicLinkToken(
251
+ token=token,
252
+ email=email,
253
+ expires_at=datetime.utcnow() + timedelta(hours=expiry_hours),
254
+ )
255
+
256
+ # Save to Firestore (same collection as magic links for unified verification)
257
+ doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
258
+ doc_ref.set(admin_login.model_dump(mode='json'))
259
+
260
+ # Log with redacted email (show only domain) for PII protection
261
+ domain = email.split('@')[-1] if '@' in email else 'unknown'
262
+ logger.info(f"Created admin login token for ***@{domain} (expires in {expiry_hours}h)")
263
+ return admin_login
264
+
197
265
  def verify_magic_link(self, token: str) -> Tuple[bool, Optional[User], str]:
198
266
  """
199
267
  Verify a magic link token using a Firestore transaction to prevent race conditions.
@@ -262,9 +330,18 @@ class UserService:
262
330
  self,
263
331
  user_email: str,
264
332
  ip_address: Optional[str] = None,
265
- user_agent: Optional[str] = None
333
+ user_agent: Optional[str] = None,
334
+ tenant_id: Optional[str] = None
266
335
  ) -> Session:
267
- """Create a new session for an authenticated user."""
336
+ """
337
+ Create a new session for an authenticated user.
338
+
339
+ Args:
340
+ user_email: User's email address
341
+ ip_address: Client IP for auditing
342
+ user_agent: Client user agent for auditing
343
+ tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
344
+ """
268
345
  token = secrets.token_urlsafe(32)
269
346
  token_prefix = token[:12]
270
347
 
@@ -274,6 +351,7 @@ class UserService:
274
351
  expires_at=datetime.utcnow() + timedelta(days=SESSION_ABSOLUTE_EXPIRY_DAYS),
275
352
  ip_address=ip_address,
276
353
  user_agent=user_agent,
354
+ tenant_id=tenant_id,
277
355
  )
278
356
 
279
357
  # Serialize and write to Firestore
backend/tests/conftest.py CHANGED
@@ -91,7 +91,13 @@ def mock_auth_dependency(request):
91
91
  if 'emulator' in test_path or 'integration' in test_path:
92
92
  yield
93
93
  return
94
-
94
+
95
+ # Skip for service-only unit tests that don't need the FastAPI app
96
+ service_only_tests = ['test_rate_limit_service', 'test_email_validation_service', 'test_rate_limits_api']
97
+ if any(test_name in test_path for test_name in service_only_tests):
98
+ yield
99
+ return
100
+
95
101
  # Skip if FIRESTORE_EMULATOR_HOST is set (running in emulator environment)
96
102
  import os
97
103
  if os.environ.get('FIRESTORE_EMULATOR_HOST'):