karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 (197) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +502 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1513 @@
1
+ """
2
+ User and authentication API routes.
3
+
4
+ Handles:
5
+ - Magic link authentication (send, verify)
6
+ - Session management (logout)
7
+ - User profile and credits
8
+ - Stripe checkout and webhooks
9
+ - Admin user management
10
+ """
11
+ import logging
12
+ from typing import Optional, Tuple
13
+ from fastapi import APIRouter, HTTPException, Depends, Request, Header
14
+ from pydantic import BaseModel, EmailStr
15
+
16
+ from backend.models.user import (
17
+ UserRole,
18
+ UserPublic,
19
+ SendMagicLinkRequest,
20
+ SendMagicLinkResponse,
21
+ VerifyMagicLinkResponse,
22
+ AddCreditsRequest,
23
+ AddCreditsResponse,
24
+ UserListResponse,
25
+ BetaTesterStatus,
26
+ BetaTesterEnrollRequest,
27
+ BetaTesterEnrollResponse,
28
+ BetaFeedbackRequest,
29
+ BetaFeedbackResponse,
30
+ BetaTesterFeedback,
31
+ )
32
+ from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
33
+ from backend.services.email_service import get_email_service, EmailService
34
+ from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
35
+ from backend.services.theme_service import get_theme_service
36
+ from backend.api.dependencies import require_admin
37
+ from backend.api.routes.file_upload import _prepare_theme_for_job
38
+ from backend.services.auth_service import UserType
39
+ from backend.utils.test_data import is_test_email
40
+
41
+
42
+ logger = logging.getLogger(__name__)
43
+ router = APIRouter(prefix="/users", tags=["users"])
44
+
45
+
46
+ # =============================================================================
47
+ # Request/Response Models
48
+ # =============================================================================
49
+
50
+ class CreateCheckoutRequest(BaseModel):
51
+ """Request to create a Stripe checkout session."""
52
+ package_id: str
53
+ email: EmailStr
54
+
55
+
56
+ class CreateCheckoutResponse(BaseModel):
57
+ """Response with checkout URL."""
58
+ status: str
59
+ checkout_url: str
60
+ message: str
61
+
62
+
63
+ class DoneForYouCheckoutRequest(BaseModel):
64
+ """Request to create a done-for-you karaoke video order."""
65
+ email: EmailStr
66
+ artist: str
67
+ title: str
68
+ source_type: str = "search" # search, youtube, or upload
69
+ youtube_url: Optional[str] = None
70
+ notes: Optional[str] = None
71
+
72
+
73
+ class CreditPackage(BaseModel):
74
+ """Credit package information."""
75
+ id: str
76
+ credits: int
77
+ price_cents: int
78
+ name: str
79
+ description: str
80
+
81
+
82
+ class CreditPackagesResponse(BaseModel):
83
+ """Response listing available credit packages."""
84
+ packages: list[CreditPackage]
85
+
86
+
87
+ class UserProfileResponse(BaseModel):
88
+ """Response with user profile."""
89
+ user: UserPublic
90
+ has_session: bool
91
+
92
+
93
+ class LogoutResponse(BaseModel):
94
+ """Response after logout."""
95
+ status: str
96
+ message: str
97
+
98
+
99
+ # =============================================================================
100
+ # Magic Link Authentication
101
+ # =============================================================================
102
+
103
+ @router.post("/auth/magic-link", response_model=SendMagicLinkResponse)
104
+ async def send_magic_link(
105
+ request: SendMagicLinkRequest,
106
+ http_request: Request,
107
+ user_service: UserService = Depends(get_user_service),
108
+ email_service: EmailService = Depends(get_email_service),
109
+ ):
110
+ """
111
+ Send a magic link email for passwordless authentication.
112
+
113
+ The user will receive an email with a link that logs them in.
114
+ Links expire after 15 minutes and can only be used once.
115
+ """
116
+ # Check if email service is configured
117
+ if not email_service.is_configured():
118
+ logger.error("Email service not configured - cannot send magic links")
119
+ raise HTTPException(
120
+ status_code=503,
121
+ detail="Email service is not available. Please contact support."
122
+ )
123
+
124
+ email = request.email.lower()
125
+
126
+ # Get client info for security logging
127
+ ip_address = http_request.client.host if http_request.client else None
128
+ user_agent = http_request.headers.get("user-agent")
129
+
130
+ # Create magic link token
131
+ magic_link = user_service.create_magic_link(
132
+ email,
133
+ ip_address=ip_address,
134
+ user_agent=user_agent
135
+ )
136
+
137
+ # Send email
138
+ sent = email_service.send_magic_link(email, magic_link.token)
139
+
140
+ if not sent:
141
+ logger.error(f"Failed to send magic link email to {email}")
142
+ # Don't reveal failure to prevent email enumeration
143
+ # Still return success
144
+
145
+ return SendMagicLinkResponse(
146
+ status="success",
147
+ message="If this email is registered, you will receive a sign-in link shortly."
148
+ )
149
+
150
+
151
+ @router.get("/auth/verify", response_model=VerifyMagicLinkResponse)
152
+ async def verify_magic_link(
153
+ token: str,
154
+ http_request: Request,
155
+ user_service: UserService = Depends(get_user_service),
156
+ email_service: EmailService = Depends(get_email_service),
157
+ ):
158
+ """
159
+ Verify a magic link token and create a session.
160
+
161
+ Returns a session token that should be stored and used for subsequent requests.
162
+ """
163
+ # Get client info
164
+ ip_address = http_request.client.host if http_request.client else None
165
+ user_agent = http_request.headers.get("user-agent")
166
+
167
+ # Check if this is a first login BEFORE verification (which sets last_login_at)
168
+ # We need to get the user's state before verify_magic_link updates it
169
+ from backend.services.user_service import MAGIC_LINKS_COLLECTION
170
+ magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
171
+ is_first_login = False
172
+ if magic_link_doc.exists:
173
+ magic_link_data = magic_link_doc.to_dict()
174
+ pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
175
+ if pre_verify_user:
176
+ is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
177
+
178
+ # Verify the magic link
179
+ success, user, message = user_service.verify_magic_link(token)
180
+
181
+ if not success or not user:
182
+ raise HTTPException(status_code=401, detail=message)
183
+
184
+ # Create session
185
+ session = user_service.create_session(
186
+ user.email,
187
+ ip_address=ip_address,
188
+ user_agent=user_agent
189
+ )
190
+
191
+ # Send welcome email to first-time users
192
+ if is_first_login:
193
+ email_service.send_welcome_email(user.email, user.credits)
194
+
195
+ # Return user info
196
+ user_public = UserPublic(
197
+ email=user.email,
198
+ role=user.role,
199
+ credits=user.credits,
200
+ display_name=user.display_name,
201
+ total_jobs_created=user.total_jobs_created,
202
+ total_jobs_completed=user.total_jobs_completed,
203
+ )
204
+
205
+ return VerifyMagicLinkResponse(
206
+ status="success",
207
+ session_token=session.token,
208
+ user=user_public,
209
+ message="Successfully signed in"
210
+ )
211
+
212
+
213
+ @router.post("/auth/logout", response_model=LogoutResponse)
214
+ async def logout(
215
+ authorization: Optional[str] = Header(None),
216
+ user_service: UserService = Depends(get_user_service),
217
+ ):
218
+ """
219
+ Logout and invalidate the current session.
220
+ """
221
+ if not authorization:
222
+ return LogoutResponse(status="success", message="Already logged out")
223
+
224
+ # Extract token from "Bearer <token>"
225
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
226
+
227
+ user_service.revoke_session(token)
228
+
229
+ return LogoutResponse(status="success", message="Successfully logged out")
230
+
231
+
232
+ # =============================================================================
233
+ # User Profile
234
+ # =============================================================================
235
+
236
+ @router.get("/me", response_model=UserProfileResponse)
237
+ async def get_current_user(
238
+ authorization: Optional[str] = Header(None),
239
+ user_service: UserService = Depends(get_user_service),
240
+ ):
241
+ """
242
+ Get the current user's profile.
243
+
244
+ Requires a valid session token or admin token.
245
+ """
246
+ from backend.services.auth_service import get_auth_service
247
+
248
+ if not authorization:
249
+ raise HTTPException(status_code=401, detail="Not authenticated")
250
+
251
+ # Extract token
252
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
253
+
254
+ # First try auth service (handles admin tokens and auth_tokens)
255
+ auth_service = get_auth_service()
256
+ auth_result = auth_service.validate_token_full(token)
257
+
258
+ if auth_result.is_valid and auth_result.user_email:
259
+ # Get or create user record for the authenticated email
260
+ user = user_service.get_or_create_user(auth_result.user_email)
261
+ user_public = UserPublic(
262
+ email=user.email,
263
+ role=user.role if not auth_result.is_admin else UserRole.ADMIN,
264
+ credits=user.credits,
265
+ display_name=user.display_name,
266
+ total_jobs_created=user.total_jobs_created,
267
+ total_jobs_completed=user.total_jobs_completed,
268
+ )
269
+ return UserProfileResponse(user=user_public, has_session=True)
270
+
271
+ # Fall back to session validation (for magic link sessions)
272
+ valid, user, message = user_service.validate_session(token)
273
+
274
+ if not valid or not user:
275
+ raise HTTPException(status_code=401, detail=message)
276
+
277
+ user_public = UserPublic(
278
+ email=user.email,
279
+ role=user.role,
280
+ credits=user.credits,
281
+ display_name=user.display_name,
282
+ total_jobs_created=user.total_jobs_created,
283
+ total_jobs_completed=user.total_jobs_completed,
284
+ )
285
+
286
+ return UserProfileResponse(user=user_public, has_session=True)
287
+
288
+
289
+ # =============================================================================
290
+ # Credit Packages & Checkout
291
+ # =============================================================================
292
+
293
+ @router.get("/credits/packages", response_model=CreditPackagesResponse)
294
+ async def list_credit_packages():
295
+ """
296
+ List available credit packages for purchase.
297
+
298
+ No authentication required - this is public information.
299
+ """
300
+ packages = [
301
+ CreditPackage(
302
+ id=pkg_id,
303
+ credits=pkg["credits"],
304
+ price_cents=pkg["price_cents"],
305
+ name=pkg["name"],
306
+ description=pkg["description"],
307
+ )
308
+ for pkg_id, pkg in CREDIT_PACKAGES.items()
309
+ ]
310
+
311
+ return CreditPackagesResponse(packages=packages)
312
+
313
+
314
+ @router.post("/credits/checkout", response_model=CreateCheckoutResponse)
315
+ async def create_checkout(
316
+ request: CreateCheckoutRequest,
317
+ stripe_service: StripeService = Depends(get_stripe_service),
318
+ ):
319
+ """
320
+ Create a Stripe checkout session for purchasing credits.
321
+
322
+ Returns a URL to redirect the user to Stripe's hosted checkout page.
323
+ No authentication required - email is provided in the request.
324
+ """
325
+ if not stripe_service.is_configured():
326
+ raise HTTPException(status_code=503, detail="Payment processing is not available")
327
+
328
+ success, checkout_url, message = stripe_service.create_checkout_session(
329
+ package_id=request.package_id,
330
+ user_email=request.email,
331
+ )
332
+
333
+ if not success or not checkout_url:
334
+ raise HTTPException(status_code=400, detail=message)
335
+
336
+ return CreateCheckoutResponse(
337
+ status="success",
338
+ checkout_url=checkout_url,
339
+ message=message,
340
+ )
341
+
342
+
343
+ @router.post("/done-for-you/checkout", response_model=CreateCheckoutResponse)
344
+ async def create_done_for_you_checkout(
345
+ request: DoneForYouCheckoutRequest,
346
+ stripe_service: StripeService = Depends(get_stripe_service),
347
+ ):
348
+ """
349
+ Create a Stripe checkout session for a done-for-you karaoke video order.
350
+
351
+ This is the full-service option where Nomad Karaoke handles everything:
352
+ - Finding or processing the audio
353
+ - Reviewing and correcting lyrics
354
+ - Selecting the best instrumental
355
+ - Generating the final video
356
+
357
+ $15 with 24-hour delivery guarantee.
358
+ No authentication required - customer email is provided in the request.
359
+ """
360
+ if not stripe_service.is_configured():
361
+ raise HTTPException(status_code=503, detail="Payment processing is not available")
362
+
363
+ success, checkout_url, message = stripe_service.create_done_for_you_checkout_session(
364
+ customer_email=request.email,
365
+ artist=request.artist,
366
+ title=request.title,
367
+ source_type=request.source_type,
368
+ youtube_url=request.youtube_url,
369
+ notes=request.notes,
370
+ )
371
+
372
+ if not success or not checkout_url:
373
+ raise HTTPException(status_code=400, detail=message)
374
+
375
+ return CreateCheckoutResponse(
376
+ status="success",
377
+ checkout_url=checkout_url,
378
+ message=message,
379
+ )
380
+
381
+
382
+ # =============================================================================
383
+ # Stripe Webhooks
384
+ # =============================================================================
385
+
386
+ # Admin email for done-for-you order notifications
387
+ ADMIN_EMAIL = "andrew@nomadkaraoke.com"
388
+
389
+
390
+ async def _handle_done_for_you_order(
391
+ session_id: str,
392
+ metadata: dict,
393
+ user_service: UserService,
394
+ email_service: EmailService,
395
+ ) -> None:
396
+ """
397
+ Handle a completed done-for-you order by creating a job and notifying Andrew.
398
+
399
+ For orders with a YouTube URL, the job is created and workers are triggered immediately.
400
+ For orders without a URL (search mode), the audio search flow is used to find and
401
+ download the best matching audio source automatically.
402
+
403
+ Args:
404
+ session_id: Stripe checkout session ID
405
+ metadata: Order metadata from Stripe session
406
+ user_service: User service for marking session processed
407
+ email_service: Email service for notifications
408
+ """
409
+ from backend.models.job import JobCreate, JobStatus
410
+ from backend.services.job_manager import JobManager
411
+ from backend.services.worker_service import get_worker_service
412
+ from backend.services.audio_search_service import (
413
+ get_audio_search_service,
414
+ NoResultsError,
415
+ AudioSearchError,
416
+ )
417
+ from backend.services.storage_service import StorageService
418
+ import asyncio
419
+ import tempfile
420
+ import os
421
+
422
+ customer_email = metadata.get("customer_email", "")
423
+ artist = metadata.get("artist", "Unknown Artist")
424
+ title = metadata.get("title", "Unknown Title")
425
+ source_type = metadata.get("source_type", "search")
426
+ youtube_url = metadata.get("youtube_url")
427
+ notes = metadata.get("notes", "")
428
+
429
+ logger.info(
430
+ f"Processing done-for-you order: {artist} - {title} for {customer_email} "
431
+ f"(session: {session_id}, source_type: {source_type})"
432
+ )
433
+
434
+ try:
435
+ job_manager = JobManager()
436
+ worker_service = get_worker_service()
437
+ storage_service = StorageService()
438
+
439
+ # Apply default theme (Nomad) - same as audio_search endpoint
440
+ theme_service = get_theme_service()
441
+ effective_theme_id = theme_service.get_default_theme_id()
442
+ if effective_theme_id:
443
+ logger.info(f"Applying default theme '{effective_theme_id}' for done-for-you order")
444
+
445
+ # Create job for the customer
446
+ # Note: done-for-you jobs should NOT be non_interactive - Andrew needs to review
447
+ job_create = JobCreate(
448
+ url=youtube_url if youtube_url else None,
449
+ artist=artist,
450
+ title=title,
451
+ user_email=customer_email, # Customer owns the job
452
+ theme_id=effective_theme_id, # Apply default theme
453
+ non_interactive=False, # Andrew will review lyrics/instrumental
454
+ # Set audio search fields for search-based orders
455
+ audio_search_artist=artist if not youtube_url else None,
456
+ audio_search_title=title if not youtube_url else None,
457
+ auto_download=True, # Auto-select best audio source
458
+ )
459
+ job = job_manager.create_job(job_create)
460
+ job_id = job.job_id
461
+
462
+ logger.info(f"Created done-for-you job {job_id} for {customer_email}")
463
+
464
+ # Prepare theme style assets for the job (same as audio_search endpoint)
465
+ if effective_theme_id:
466
+ try:
467
+ style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
468
+ job_id, effective_theme_id, None # No color overrides for done-for-you
469
+ )
470
+ theme_update = {
471
+ 'style_params_gcs_path': style_params_path,
472
+ 'style_assets': theme_style_assets,
473
+ }
474
+ if youtube_desc:
475
+ theme_update['youtube_description_template'] = youtube_desc
476
+ job_manager.update_job(job_id, theme_update)
477
+ logger.info(f"Applied theme '{effective_theme_id}' to done-for-you job {job_id}")
478
+ except Exception as e:
479
+ logger.warning(f"Failed to prepare theme for done-for-you job {job_id}: {e}")
480
+
481
+ # Mark session as processed for idempotency
482
+ # Note: Using internal method since this isn't a credit transaction
483
+ user_service._mark_stripe_session_processed(
484
+ stripe_session_id=session_id,
485
+ email=customer_email,
486
+ amount=0 # No credits, just tracking the session
487
+ )
488
+
489
+ # Handle based on whether we have a YouTube URL or need to search
490
+ if youtube_url:
491
+ # URL provided - trigger workers directly
492
+ logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
493
+ await asyncio.gather(
494
+ worker_service.trigger_audio_worker(job_id),
495
+ worker_service.trigger_lyrics_worker(job_id)
496
+ )
497
+ else:
498
+ # No URL - use audio search flow with auto_download
499
+ logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
500
+
501
+ # Update job with audio search fields
502
+ job_manager.update_job(job_id, {
503
+ 'audio_search_artist': artist,
504
+ 'audio_search_title': title,
505
+ 'auto_download': True,
506
+ })
507
+
508
+ # Transition to searching state
509
+ job_manager.transition_to_state(
510
+ job_id=job_id,
511
+ new_status=JobStatus.SEARCHING_AUDIO,
512
+ progress=5,
513
+ message=f"Searching for audio: {artist} - {title}"
514
+ )
515
+
516
+ # Perform audio search
517
+ audio_search_service = get_audio_search_service()
518
+
519
+ try:
520
+ search_results = audio_search_service.search(artist, title)
521
+ except NoResultsError as e:
522
+ # No results found - transition to AWAITING_AUDIO_SELECTION so Andrew can handle manually
523
+ logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
524
+ job_manager.transition_to_state(
525
+ job_id=job_id,
526
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
527
+ progress=10,
528
+ message=f"No automatic audio sources found. Manual intervention required."
529
+ )
530
+ # Don't fail the job - Andrew can manually provide audio
531
+ search_results = None
532
+ except AudioSearchError as e:
533
+ logger.error(f"Job {job_id}: Audio search failed: {e}")
534
+ job_manager.transition_to_state(
535
+ job_id=job_id,
536
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
537
+ progress=10,
538
+ message=f"Audio search error. Manual intervention required."
539
+ )
540
+ search_results = None
541
+
542
+ if search_results:
543
+ # Store search results in state_data
544
+ results_dicts = [r.to_dict() for r in search_results]
545
+ state_data_update = {
546
+ 'audio_search_results': results_dicts,
547
+ 'audio_search_count': len(results_dicts),
548
+ }
549
+ if audio_search_service.last_remote_search_id:
550
+ state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
551
+ job_manager.update_job(job_id, {'state_data': state_data_update})
552
+
553
+ # Auto-select best result and download
554
+ best_index = audio_search_service.select_best(search_results)
555
+ selected = results_dicts[best_index]
556
+
557
+ logger.info(f"Job {job_id}: Auto-selected result {best_index}: {selected.get('provider')} - {selected.get('title')}")
558
+
559
+ # Transition to downloading state
560
+ job_manager.transition_to_state(
561
+ job_id=job_id,
562
+ new_status=JobStatus.DOWNLOADING_AUDIO,
563
+ progress=10,
564
+ message=f"Downloading from {selected.get('provider')}: {selected.get('artist')} - {selected.get('title')}",
565
+ state_data_updates={
566
+ 'selected_audio_index': best_index,
567
+ 'selected_audio_provider': selected.get('provider'),
568
+ }
569
+ )
570
+
571
+ # Download audio
572
+ try:
573
+ is_torrent_source = selected.get('provider') in ['RED', 'OPS']
574
+ is_remote_enabled = audio_search_service.is_remote_enabled()
575
+ source_id = selected.get('source_id')
576
+ source_name = selected.get('provider')
577
+ target_file = selected.get('target_file')
578
+ download_url = selected.get('url')
579
+ remote_search_id = state_data_update.get('remote_search_id')
580
+
581
+ if is_torrent_source and is_remote_enabled:
582
+ # Remote torrent download - upload directly to GCS
583
+ gcs_destination = f"uploads/{job_id}/audio/"
584
+
585
+ if source_id and source_name:
586
+ result = audio_search_service.download_by_id(
587
+ source_name=source_name,
588
+ source_id=source_id,
589
+ output_dir="",
590
+ target_file=target_file,
591
+ download_url=download_url,
592
+ gcs_path=gcs_destination,
593
+ )
594
+ else:
595
+ result = audio_search_service.download(
596
+ result_index=best_index,
597
+ output_dir="",
598
+ gcs_path=gcs_destination,
599
+ remote_search_id=remote_search_id,
600
+ )
601
+
602
+ # Extract GCS path
603
+ if result.filepath.startswith("gs://"):
604
+ parts = result.filepath.replace("gs://", "").split("/", 1)
605
+ audio_gcs_path = parts[1] if len(parts) == 2 else result.filepath
606
+ filename = os.path.basename(result.filepath)
607
+ else:
608
+ filename = os.path.basename(result.filepath)
609
+ audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
610
+ else:
611
+ # Local download (YouTube or local torrent)
612
+ temp_dir = tempfile.mkdtemp(prefix=f"audio_download_{job_id}_")
613
+ import shutil
614
+
615
+ try:
616
+ if source_id and source_name and is_remote_enabled:
617
+ result = audio_search_service.download_by_id(
618
+ source_name=source_name,
619
+ source_id=source_id,
620
+ output_dir=temp_dir,
621
+ target_file=target_file,
622
+ download_url=download_url,
623
+ )
624
+ elif source_name == 'YouTube' and download_url:
625
+ # YouTube download
626
+ from backend.workers.audio_worker import download_from_url
627
+ local_path = await download_from_url(
628
+ download_url,
629
+ temp_dir,
630
+ selected.get('artist'),
631
+ selected.get('title')
632
+ )
633
+ if not local_path or not os.path.exists(local_path):
634
+ raise Exception(f"Failed to download from YouTube: {download_url}")
635
+
636
+ class DownloadResult:
637
+ def __init__(self, filepath):
638
+ self.filepath = filepath
639
+ result = DownloadResult(local_path)
640
+ else:
641
+ result = audio_search_service.download(
642
+ result_index=best_index,
643
+ output_dir=temp_dir,
644
+ remote_search_id=remote_search_id,
645
+ )
646
+
647
+ # Upload to GCS
648
+ filename = os.path.basename(result.filepath)
649
+ audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
650
+
651
+ with open(result.filepath, 'rb') as f:
652
+ storage_service.upload_fileobj(f, audio_gcs_path, content_type='audio/flac')
653
+ finally:
654
+ # Always cleanup temp directory
655
+ shutil.rmtree(temp_dir, ignore_errors=True)
656
+
657
+ # Update job with GCS path
658
+ job_manager.update_job(job_id, {
659
+ 'input_media_gcs_path': audio_gcs_path,
660
+ 'filename': filename,
661
+ })
662
+
663
+ # Transition to DOWNLOADING and trigger workers
664
+ job_manager.transition_to_state(
665
+ job_id=job_id,
666
+ new_status=JobStatus.DOWNLOADING,
667
+ progress=15,
668
+ message="Audio downloaded, starting processing"
669
+ )
670
+
671
+ # Trigger workers
672
+ await asyncio.gather(
673
+ worker_service.trigger_audio_worker(job_id),
674
+ worker_service.trigger_lyrics_worker(job_id)
675
+ )
676
+
677
+ logger.info(f"Job {job_id}: Audio downloaded and workers triggered")
678
+
679
+ except Exception as download_error:
680
+ logger.error(f"Job {job_id}: Audio download failed: {download_error}")
681
+ # Don't fail job - transition to awaiting selection so Andrew can handle
682
+ job_manager.transition_to_state(
683
+ job_id=job_id,
684
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
685
+ progress=10,
686
+ message=f"Auto-download failed: {download_error}. Manual intervention required."
687
+ )
688
+
689
+ # Send confirmation email to customer
690
+ email_service.send_email(
691
+ to_email=customer_email,
692
+ subject=f"Your Karaoke Video Order: {artist} - {title}",
693
+ html_content=f"""
694
+ <h2>Thank you for your order!</h2>
695
+ <p>We've received your request for a karaoke video:</p>
696
+ <ul>
697
+ <li><strong>Artist:</strong> {artist}</li>
698
+ <li><strong>Title:</strong> {title}</li>
699
+ {f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
700
+ </ul>
701
+ <p>Our team will review and create your video within <strong>24 hours</strong>.</p>
702
+ <p>You'll receive another email with download links when your video is ready.</p>
703
+ <p>If you have any questions, reply to this email or contact us at support@nomadkaraoke.com</p>
704
+ <p>Thanks for using Nomad Karaoke!</p>
705
+ """,
706
+ text_content=f"""
707
+ Thank you for your order!
708
+
709
+ We've received your request for a karaoke video:
710
+ - Artist: {artist}
711
+ - Title: {title}
712
+ {f'- Notes: {notes}' if notes else ''}
713
+
714
+ Our team will review and create your video within 24 hours.
715
+ You'll receive another email with download links when your video is ready.
716
+
717
+ If you have any questions, reply to this email or contact us at support@nomadkaraoke.com
718
+
719
+ Thanks for using Nomad Karaoke!
720
+ """.strip(),
721
+ )
722
+
723
+ # Send notification email to Andrew
724
+ email_service.send_email(
725
+ to_email=ADMIN_EMAIL,
726
+ subject=f"[Done For You Order] {artist} - {title}",
727
+ html_content=f"""
728
+ <h2>New Done-For-You Order</h2>
729
+ <p>A customer has ordered a karaoke video:</p>
730
+ <ul>
731
+ <li><strong>Customer:</strong> {customer_email}</li>
732
+ <li><strong>Artist:</strong> {artist}</li>
733
+ <li><strong>Title:</strong> {title}</li>
734
+ <li><strong>Source:</strong> {source_type}</li>
735
+ {f'<li><strong>YouTube URL:</strong> <a href="{youtube_url}">{youtube_url}</a></li>' if youtube_url else ''}
736
+ {f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
737
+ </ul>
738
+ <p><strong>Job ID:</strong> {job_id}</p>
739
+ <p>View job in admin: <a href="https://gen.nomadkaraoke.com/admin/jobs/{job_id}">Admin Link</a></p>
740
+ <p>View job as customer: <a href="https://gen.nomadkaraoke.com/jobs/{job_id}">Customer Link</a></p>
741
+ <p><strong>Deadline:</strong> 24 hours from now</p>
742
+ """,
743
+ text_content=f"""
744
+ New Done-For-You Order
745
+
746
+ Customer: {customer_email}
747
+ Artist: {artist}
748
+ Title: {title}
749
+ Source: {source_type}
750
+ {f'YouTube URL: {youtube_url}' if youtube_url else ''}
751
+ {f'Notes: {notes}' if notes else ''}
752
+
753
+ Job ID: {job_id}
754
+ Admin: https://gen.nomadkaraoke.com/admin/jobs/{job_id}
755
+ Customer: https://gen.nomadkaraoke.com/jobs/{job_id}
756
+
757
+ Deadline: 24 hours from now
758
+ """.strip(),
759
+ )
760
+
761
+ logger.info(f"Sent done-for-you order notifications for job {job_id}")
762
+
763
+ except Exception as e:
764
+ logger.error(f"Error processing done-for-you order: {e}", exc_info=True)
765
+ # Still try to notify Andrew of the failure
766
+ try:
767
+ email_service.send_email(
768
+ to_email=ADMIN_EMAIL,
769
+ subject=f"[FAILED] Done For You Order: {artist} - {title}",
770
+ html_content=f"""
771
+ <h2>Done-For-You Order Failed</h2>
772
+ <p>An error occurred processing this order:</p>
773
+ <ul>
774
+ <li><strong>Customer:</strong> {customer_email}</li>
775
+ <li><strong>Artist:</strong> {artist}</li>
776
+ <li><strong>Title:</strong> {title}</li>
777
+ <li><strong>Error:</strong> {str(e)}</li>
778
+ </ul>
779
+ <p>Please manually create this job and notify the customer.</p>
780
+ """,
781
+ text_content=f"""
782
+ Done-For-You Order Failed
783
+
784
+ Customer: {customer_email}
785
+ Artist: {artist}
786
+ Title: {title}
787
+ Error: {str(e)}
788
+
789
+ Please manually create this job and notify the customer.
790
+ """.strip(),
791
+ )
792
+ except Exception as email_error:
793
+ logger.error(f"Failed to send error notification: {email_error}")
794
+
795
+
796
+ @router.post("/webhooks/stripe")
797
+ async def stripe_webhook(
798
+ request: Request,
799
+ stripe_signature: str = Header(None, alias="stripe-signature"),
800
+ stripe_service: StripeService = Depends(get_stripe_service),
801
+ user_service: UserService = Depends(get_user_service),
802
+ email_service: EmailService = Depends(get_email_service),
803
+ ):
804
+ """
805
+ Handle Stripe webhook events.
806
+
807
+ This endpoint receives events from Stripe about payment status.
808
+ It verifies the webhook signature and processes the event.
809
+ """
810
+ if not stripe_signature:
811
+ raise HTTPException(status_code=400, detail="Missing Stripe signature")
812
+
813
+ # Get raw body for signature verification
814
+ payload = await request.body()
815
+
816
+ # Verify signature
817
+ valid, event, message = stripe_service.verify_webhook_signature(payload, stripe_signature)
818
+
819
+ if not valid:
820
+ logger.warning(f"Invalid Stripe webhook signature: {message}")
821
+ raise HTTPException(status_code=400, detail=message)
822
+
823
+ # Handle the event
824
+ event_type = event.get("type")
825
+ logger.info(f"Received Stripe webhook: {event_type}")
826
+
827
+ if event_type == "checkout.session.completed":
828
+ session = event["data"]["object"]
829
+ session_id = session.get("id")
830
+ metadata = session.get("metadata", {})
831
+
832
+ # Idempotency check: Skip if this session was already processed
833
+ if session_id and user_service.is_stripe_session_processed(session_id):
834
+ logger.info(f"Skipping already processed session: {session_id}")
835
+ return {"status": "received", "type": event_type, "note": "already_processed"}
836
+
837
+ # Check if this is a done-for-you order
838
+ if metadata.get("order_type") == "done_for_you":
839
+ # Handle done-for-you order - create a job
840
+ await _handle_done_for_you_order(
841
+ session_id=session_id,
842
+ metadata=metadata,
843
+ user_service=user_service,
844
+ email_service=email_service,
845
+ )
846
+ else:
847
+ # Handle regular credit purchase
848
+ success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
849
+
850
+ if success and user_email and credits > 0:
851
+ # Add credits to user account
852
+ ok, new_balance, credit_msg = user_service.add_credits(
853
+ email=user_email,
854
+ amount=credits,
855
+ reason="stripe_purchase",
856
+ stripe_session_id=session_id,
857
+ )
858
+
859
+ if ok:
860
+ # Send confirmation email
861
+ email_service.send_credits_added(user_email, credits, new_balance)
862
+ logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
863
+ else:
864
+ logger.error(f"Failed to add credits: {credit_msg}")
865
+
866
+ elif event_type == "checkout.session.expired":
867
+ logger.info(f"Checkout session expired: {event['data']['object'].get('id')}")
868
+
869
+ elif event_type == "payment_intent.payment_failed":
870
+ logger.warning(f"Payment failed: {event['data']['object'].get('id')}")
871
+
872
+ # Return 200 to acknowledge receipt (Stripe will retry on non-2xx)
873
+ return {"status": "received", "type": event_type}
874
+
875
+
876
+ # =============================================================================
877
+ # Beta Tester Program
878
+ # =============================================================================
879
+
880
+ BETA_TESTER_FREE_CREDITS = 1 # Number of free credits for beta testers
881
+
882
+
883
+ @router.post("/beta/enroll", response_model=BetaTesterEnrollResponse)
884
+ async def enroll_beta_tester(
885
+ request: BetaTesterEnrollRequest,
886
+ http_request: Request,
887
+ user_service: UserService = Depends(get_user_service),
888
+ email_service: EmailService = Depends(get_email_service),
889
+ ):
890
+ """
891
+ Enroll as a beta tester to receive free karaoke credits.
892
+
893
+ Requirements:
894
+ - Accept that there may be work to review/correct lyrics
895
+ - Promise to provide feedback after using the tool
896
+
897
+ Returns free credits and optionally a session token for new users.
898
+ """
899
+ # Check if email service is configured
900
+ if not email_service.is_configured():
901
+ logger.error("Email service not configured - cannot send beta welcome emails")
902
+ raise HTTPException(
903
+ status_code=503,
904
+ detail="Email service is not available. Please contact support."
905
+ )
906
+
907
+ email = request.email.lower()
908
+
909
+ # Validate acceptance
910
+ if not request.accept_corrections_work:
911
+ raise HTTPException(
912
+ status_code=400,
913
+ detail="You must accept that you may need to review/correct lyrics"
914
+ )
915
+
916
+ if len(request.promise_text.strip()) < 10:
917
+ raise HTTPException(
918
+ status_code=400,
919
+ detail="Please write a sentence confirming your promise to provide feedback"
920
+ )
921
+
922
+ # Get or create user
923
+ user = user_service.get_or_create_user(email)
924
+
925
+ # Check if already enrolled as beta tester
926
+ if user.is_beta_tester:
927
+ raise HTTPException(
928
+ status_code=400,
929
+ detail="You are already enrolled in the beta program"
930
+ )
931
+
932
+ # Get client info
933
+ ip_address = http_request.client.host if http_request.client else None
934
+ user_agent = http_request.headers.get("user-agent")
935
+
936
+ # Enroll as beta tester
937
+ from datetime import datetime
938
+ user_service.update_user(
939
+ email,
940
+ is_beta_tester=True,
941
+ beta_tester_status=BetaTesterStatus.ACTIVE.value,
942
+ beta_enrolled_at=datetime.utcnow(),
943
+ beta_promise_text=request.promise_text.strip(),
944
+ )
945
+
946
+ # Add free credits
947
+ _, new_balance, _ = user_service.add_credits(
948
+ email=email,
949
+ amount=BETA_TESTER_FREE_CREDITS,
950
+ reason="beta_tester_enrollment",
951
+ )
952
+
953
+ # Create session for the user (so they can start using the service immediately)
954
+ session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
955
+
956
+ # Send welcome email
957
+ email_service.send_beta_welcome_email(email, BETA_TESTER_FREE_CREDITS)
958
+
959
+ logger.info(f"Beta tester enrolled: {email}, granted {BETA_TESTER_FREE_CREDITS} credits")
960
+
961
+ return BetaTesterEnrollResponse(
962
+ status="success",
963
+ message=f"Welcome to the beta program! You have {new_balance} free credits.",
964
+ credits_granted=BETA_TESTER_FREE_CREDITS,
965
+ session_token=session.token,
966
+ )
967
+
968
+
969
+ @router.post("/beta/feedback", response_model=BetaFeedbackResponse)
970
+ async def submit_beta_feedback(
971
+ request: BetaFeedbackRequest,
972
+ authorization: Optional[str] = Header(None),
973
+ user_service: UserService = Depends(get_user_service),
974
+ ):
975
+ """
976
+ Submit feedback as a beta tester.
977
+
978
+ Requires authentication. Updates beta tester status to completed.
979
+ May grant bonus credits for detailed feedback.
980
+ """
981
+ if not authorization:
982
+ raise HTTPException(status_code=401, detail="Authentication required")
983
+
984
+ # Extract token
985
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
986
+
987
+ # Validate session
988
+ valid, user, message = user_service.validate_session(token)
989
+ if not valid or not user:
990
+ raise HTTPException(status_code=401, detail=message)
991
+
992
+ # Check if user is a beta tester
993
+ if not user.is_beta_tester:
994
+ raise HTTPException(
995
+ status_code=400,
996
+ detail="Only beta testers can submit feedback through this endpoint"
997
+ )
998
+
999
+ # Check if already completed feedback
1000
+ if user.beta_tester_status == BetaTesterStatus.COMPLETED:
1001
+ raise HTTPException(
1002
+ status_code=400,
1003
+ detail="You have already submitted feedback. Thank you!"
1004
+ )
1005
+
1006
+ # Save feedback to Firestore
1007
+ import uuid
1008
+
1009
+ feedback = BetaTesterFeedback(
1010
+ id=str(uuid.uuid4()),
1011
+ user_email=user.email,
1012
+ job_id=request.job_id,
1013
+ overall_rating=request.overall_rating,
1014
+ ease_of_use_rating=request.ease_of_use_rating,
1015
+ lyrics_accuracy_rating=request.lyrics_accuracy_rating,
1016
+ correction_experience_rating=request.correction_experience_rating,
1017
+ what_went_well=request.what_went_well,
1018
+ what_could_improve=request.what_could_improve,
1019
+ additional_comments=request.additional_comments,
1020
+ would_recommend=request.would_recommend,
1021
+ would_use_again=request.would_use_again,
1022
+ submitted_via="web",
1023
+ )
1024
+
1025
+ # Save to Firestore
1026
+ user_service.db.collection("beta_feedback").document(feedback.id).set(
1027
+ feedback.model_dump(mode='json')
1028
+ )
1029
+
1030
+ # Update user status
1031
+ user_service.update_user(
1032
+ user.email,
1033
+ beta_tester_status=BetaTesterStatus.COMPLETED.value,
1034
+ )
1035
+
1036
+ # Calculate bonus credits for detailed feedback
1037
+ bonus_credits = 0
1038
+ has_detailed_feedback = (
1039
+ (request.what_went_well and len(request.what_went_well) > 50) or
1040
+ (request.what_could_improve and len(request.what_could_improve) > 50) or
1041
+ (request.additional_comments and len(request.additional_comments) > 50)
1042
+ )
1043
+
1044
+ if has_detailed_feedback:
1045
+ # Grant bonus credit for detailed feedback
1046
+ bonus_credits = 1
1047
+ user_service.add_credits(
1048
+ email=user.email,
1049
+ amount=bonus_credits,
1050
+ reason="beta_feedback_bonus",
1051
+ )
1052
+
1053
+ logger.info(f"Beta feedback received from {user.email}, bonus: {bonus_credits}")
1054
+
1055
+ return BetaFeedbackResponse(
1056
+ status="success",
1057
+ message="Thank you for your feedback!" + (
1058
+ f" You earned {bonus_credits} bonus credit for your detailed response!"
1059
+ if bonus_credits > 0 else ""
1060
+ ),
1061
+ bonus_credits=bonus_credits,
1062
+ )
1063
+
1064
+
1065
+ @router.get("/beta/feedback-form")
1066
+ async def get_feedback_form_data(
1067
+ authorization: Optional[str] = Header(None),
1068
+ user_service: UserService = Depends(get_user_service),
1069
+ ):
1070
+ """
1071
+ Get data needed to show the feedback form.
1072
+
1073
+ Returns whether the user needs to submit feedback and any job context.
1074
+ """
1075
+ if not authorization:
1076
+ raise HTTPException(status_code=401, detail="Authentication required")
1077
+
1078
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
1079
+ valid, user, message = user_service.validate_session(token)
1080
+
1081
+ if not valid or not user:
1082
+ raise HTTPException(status_code=401, detail=message)
1083
+
1084
+ return {
1085
+ "is_beta_tester": user.is_beta_tester,
1086
+ "beta_status": user.beta_tester_status,
1087
+ "needs_feedback": (
1088
+ user.is_beta_tester and
1089
+ user.beta_tester_status == BetaTesterStatus.PENDING_FEEDBACK.value
1090
+ ),
1091
+ "can_submit_feedback": (
1092
+ user.is_beta_tester and
1093
+ user.beta_tester_status != BetaTesterStatus.COMPLETED.value
1094
+ ),
1095
+ }
1096
+
1097
+
1098
+ # =============================================================================
1099
+ # Admin Endpoints
1100
+ # =============================================================================
1101
+
1102
+ class UserListResponsePaginated(BaseModel):
1103
+ """Paginated response for user list."""
1104
+ users: list[UserPublic]
1105
+ total: int
1106
+ offset: int
1107
+ limit: int
1108
+ has_more: bool
1109
+
1110
+
1111
+ class UserDetailResponse(BaseModel):
1112
+ """Detailed user information for admin view."""
1113
+ email: str
1114
+ role: UserRole
1115
+ credits: int
1116
+ display_name: Optional[str] = None
1117
+ is_active: bool = True
1118
+ email_verified: bool = False
1119
+ created_at: Optional[str] = None
1120
+ updated_at: Optional[str] = None
1121
+ last_login_at: Optional[str] = None
1122
+ total_jobs_created: int = 0
1123
+ total_jobs_completed: int = 0
1124
+ is_beta_tester: bool = False
1125
+ beta_tester_status: Optional[str] = None
1126
+ credit_transactions: list[dict] = []
1127
+ recent_jobs: list[dict] = []
1128
+ active_sessions_count: int = 0
1129
+
1130
+
1131
+ @router.get("/admin/users", response_model=UserListResponsePaginated)
1132
+ async def list_users(
1133
+ limit: int = 50,
1134
+ offset: int = 0,
1135
+ search: Optional[str] = None,
1136
+ sort_by: str = "created_at",
1137
+ sort_order: str = "desc",
1138
+ include_inactive: bool = False,
1139
+ exclude_test: bool = True,
1140
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1141
+ user_service: UserService = Depends(get_user_service),
1142
+ ):
1143
+ """
1144
+ List all users with search, pagination, and sorting (admin only).
1145
+
1146
+ Args:
1147
+ limit: Maximum users to return (default 50, max 100)
1148
+ offset: Number of users to skip for pagination
1149
+ search: Search by email (case-insensitive prefix match)
1150
+ sort_by: Field to sort by (created_at, last_login_at, credits, email)
1151
+ sort_order: Sort direction (asc, desc)
1152
+ include_inactive: Include disabled users
1153
+ exclude_test: If True (default), exclude test users (e.g., @inbox.testmail.app)
1154
+ """
1155
+ from google.cloud import firestore
1156
+ from google.cloud.firestore_v1 import FieldFilter
1157
+
1158
+ # Validate and cap limit
1159
+ limit = min(limit, 100)
1160
+
1161
+ db = user_service.db
1162
+ query = db.collection(USERS_COLLECTION)
1163
+
1164
+ # Filter inactive users
1165
+ if not include_inactive:
1166
+ query = query.where(filter=FieldFilter('is_active', '==', True))
1167
+
1168
+ # Search by email prefix (case-insensitive via range query)
1169
+ if search:
1170
+ search_lower = search.lower()
1171
+ # Use range query for prefix matching
1172
+ query = query.where(filter=FieldFilter('email', '>=', search_lower))
1173
+ query = query.where(filter=FieldFilter('email', '<', search_lower + '\uffff'))
1174
+
1175
+ # Sorting
1176
+ direction = firestore.Query.DESCENDING if sort_order == "desc" else firestore.Query.ASCENDING
1177
+ if sort_by in ["created_at", "last_login_at", "credits", "email"]:
1178
+ query = query.order_by(sort_by, direction=direction)
1179
+ else:
1180
+ query = query.order_by("created_at", direction=direction)
1181
+
1182
+ # Get all docs and filter in Python
1183
+ # Note: This is expensive for large datasets, consider caching
1184
+ all_docs = list(query.stream())
1185
+
1186
+ # Filter out test users if exclude_test is True
1187
+ if exclude_test:
1188
+ all_docs = [d for d in all_docs if not is_test_email(d.to_dict().get('email', ''))]
1189
+
1190
+ total_count = len(all_docs)
1191
+
1192
+ # Apply pagination manually (Firestore doesn't support offset well)
1193
+ paginated_docs = all_docs[offset:offset + limit]
1194
+
1195
+ users_public = []
1196
+ for doc in paginated_docs:
1197
+ data = doc.to_dict()
1198
+ users_public.append(UserPublic(
1199
+ email=data.get("email", ""),
1200
+ role=data.get("role", UserRole.USER),
1201
+ credits=data.get("credits", 0),
1202
+ display_name=data.get("display_name"),
1203
+ total_jobs_created=data.get("total_jobs_created", 0),
1204
+ total_jobs_completed=data.get("total_jobs_completed", 0),
1205
+ ))
1206
+
1207
+ return UserListResponsePaginated(
1208
+ users=users_public,
1209
+ total=total_count,
1210
+ offset=offset,
1211
+ limit=limit,
1212
+ has_more=(offset + limit) < total_count,
1213
+ )
1214
+
1215
+
1216
+ @router.get("/admin/users/{email}/detail", response_model=UserDetailResponse)
1217
+ async def get_user_detail(
1218
+ email: str,
1219
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1220
+ user_service: UserService = Depends(get_user_service),
1221
+ ):
1222
+ """
1223
+ Get detailed user information including credit history and recent jobs (admin only).
1224
+ """
1225
+ from google.cloud import firestore
1226
+ from google.cloud.firestore_v1 import FieldFilter
1227
+ from urllib.parse import unquote
1228
+
1229
+ # URL decode the email (handles @ and other special chars)
1230
+ email = unquote(email).lower()
1231
+
1232
+ user = user_service.get_user(email)
1233
+ if not user:
1234
+ raise HTTPException(status_code=404, detail="User not found")
1235
+
1236
+ db = user_service.db
1237
+
1238
+ # Get recent jobs for this user
1239
+ jobs_query = db.collection("jobs").where(
1240
+ filter=FieldFilter("user_email", "==", email)
1241
+ ).order_by("created_at", direction=firestore.Query.DESCENDING).limit(10)
1242
+
1243
+ recent_jobs = []
1244
+ for job_doc in jobs_query.stream():
1245
+ job_data = job_doc.to_dict()
1246
+ created_at = job_data.get("created_at")
1247
+ # Handle both datetime objects and ISO strings
1248
+ if created_at:
1249
+ created_at_str = created_at.isoformat() if hasattr(created_at, 'isoformat') else str(created_at)
1250
+ else:
1251
+ created_at_str = None
1252
+ recent_jobs.append({
1253
+ "job_id": job_data.get("job_id"),
1254
+ "status": job_data.get("status"),
1255
+ "artist": job_data.get("artist"),
1256
+ "title": job_data.get("title"),
1257
+ "created_at": created_at_str,
1258
+ })
1259
+
1260
+ # Count active sessions
1261
+ sessions_query = db.collection("sessions").where(
1262
+ filter=FieldFilter("user_email", "==", email)
1263
+ ).where(
1264
+ filter=FieldFilter("is_active", "==", True)
1265
+ )
1266
+ active_sessions_count = sum(1 for _ in sessions_query.stream())
1267
+
1268
+ # Format credit transactions
1269
+ credit_transactions = []
1270
+ for txn in user.credit_transactions[-20:]: # Last 20 transactions
1271
+ if hasattr(txn, 'model_dump'):
1272
+ credit_transactions.append(txn.model_dump(mode='json'))
1273
+ elif isinstance(txn, dict):
1274
+ credit_transactions.append(txn)
1275
+
1276
+ return UserDetailResponse(
1277
+ email=user.email,
1278
+ role=user.role,
1279
+ credits=user.credits,
1280
+ display_name=user.display_name,
1281
+ is_active=user.is_active,
1282
+ email_verified=user.email_verified,
1283
+ created_at=user.created_at.isoformat() if user.created_at else None,
1284
+ updated_at=user.updated_at.isoformat() if user.updated_at else None,
1285
+ last_login_at=user.last_login_at.isoformat() if user.last_login_at else None,
1286
+ total_jobs_created=user.total_jobs_created,
1287
+ total_jobs_completed=user.total_jobs_completed,
1288
+ is_beta_tester=user.is_beta_tester,
1289
+ beta_tester_status=user.beta_tester_status,
1290
+ credit_transactions=credit_transactions,
1291
+ recent_jobs=recent_jobs,
1292
+ active_sessions_count=active_sessions_count,
1293
+ )
1294
+
1295
+
1296
+ @router.post("/admin/credits", response_model=AddCreditsResponse)
1297
+ async def add_credits_to_user(
1298
+ request: AddCreditsRequest,
1299
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1300
+ user_service: UserService = Depends(get_user_service),
1301
+ email_service: EmailService = Depends(get_email_service),
1302
+ ):
1303
+ """
1304
+ Add credits to a user's account (admin only).
1305
+
1306
+ Use this to grant free credits to users, e.g., for beta testers or promotions.
1307
+ """
1308
+ admin_token, _, _ = auth_data
1309
+
1310
+ # TODO: Enhance auth system to track admin email identity for better audit trails.
1311
+ # Current token-based admin auth doesn't include email identity.
1312
+ # For now, we log the token prefix for traceability.
1313
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
1314
+
1315
+ success, new_balance, message = user_service.add_credits(
1316
+ email=request.email,
1317
+ amount=request.amount,
1318
+ reason=request.reason,
1319
+ admin_email=admin_id,
1320
+ )
1321
+
1322
+ if not success:
1323
+ raise HTTPException(status_code=400, detail=message)
1324
+
1325
+ # Send notification email
1326
+ if request.amount > 0:
1327
+ email_service.send_credits_added(request.email, request.amount, new_balance)
1328
+
1329
+ return AddCreditsResponse(
1330
+ status="success",
1331
+ email=request.email,
1332
+ credits_added=request.amount,
1333
+ new_balance=new_balance,
1334
+ message=message,
1335
+ )
1336
+
1337
+
1338
+ @router.post("/admin/users/{email}/disable")
1339
+ async def disable_user(
1340
+ email: str,
1341
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1342
+ user_service: UserService = Depends(get_user_service),
1343
+ ):
1344
+ """
1345
+ Disable a user account (admin only).
1346
+ """
1347
+ admin_token, _, _ = auth_data
1348
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
1349
+
1350
+ success = user_service.disable_user(email, admin_email=admin_id)
1351
+
1352
+ if not success:
1353
+ raise HTTPException(status_code=404, detail="User not found")
1354
+
1355
+ return {"status": "success", "message": f"User {email} has been disabled"}
1356
+
1357
+
1358
+ @router.post("/admin/users/{email}/enable")
1359
+ async def enable_user(
1360
+ email: str,
1361
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1362
+ user_service: UserService = Depends(get_user_service),
1363
+ ):
1364
+ """
1365
+ Enable a user account (admin only).
1366
+ """
1367
+ admin_token, _, _ = auth_data
1368
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
1369
+
1370
+ success = user_service.enable_user(email, admin_email=admin_id)
1371
+
1372
+ if not success:
1373
+ raise HTTPException(status_code=404, detail="User not found")
1374
+
1375
+ return {"status": "success", "message": f"User {email} has been enabled"}
1376
+
1377
+
1378
+ @router.post("/admin/users/{email}/role")
1379
+ async def set_user_role(
1380
+ email: str,
1381
+ role: UserRole,
1382
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1383
+ user_service: UserService = Depends(get_user_service),
1384
+ ):
1385
+ """
1386
+ Set a user's role (admin only).
1387
+ """
1388
+ admin_token, _, _ = auth_data
1389
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
1390
+
1391
+ success = user_service.set_user_role(email, role, admin_email=admin_id)
1392
+
1393
+ if not success:
1394
+ raise HTTPException(status_code=404, detail="User not found")
1395
+
1396
+ return {"status": "success", "message": f"User {email} role set to {role.value}"}
1397
+
1398
+
1399
+ @router.get("/admin/beta/feedback")
1400
+ async def list_beta_feedback(
1401
+ limit: int = 50,
1402
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1403
+ user_service: UserService = Depends(get_user_service),
1404
+ ):
1405
+ """
1406
+ List all beta tester feedback (admin only).
1407
+ """
1408
+ from google.cloud import firestore
1409
+
1410
+ query = user_service.db.collection("beta_feedback")
1411
+ query = query.order_by("created_at", direction=firestore.Query.DESCENDING)
1412
+ query = query.limit(limit)
1413
+
1414
+ docs = query.stream()
1415
+ feedback_list = [doc.to_dict() for doc in docs]
1416
+
1417
+ return {
1418
+ "feedback": feedback_list,
1419
+ "total": len(feedback_list),
1420
+ }
1421
+
1422
+
1423
+ @router.get("/admin/beta/stats")
1424
+ async def get_beta_stats(
1425
+ exclude_test: bool = True,
1426
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1427
+ user_service: UserService = Depends(get_user_service),
1428
+ ):
1429
+ """
1430
+ Get beta tester program statistics (admin only).
1431
+
1432
+ Args:
1433
+ exclude_test: If True (default), exclude test users from beta stats
1434
+ """
1435
+ from google.cloud.firestore_v1 import FieldFilter
1436
+ from google.cloud.firestore_v1 import aggregation
1437
+
1438
+ users_collection = user_service.db.collection(USERS_COLLECTION)
1439
+
1440
+ if exclude_test:
1441
+ # Stream and filter in Python since Firestore doesn't support "not ends with"
1442
+ all_beta_users = []
1443
+ for doc in users_collection.where(filter=FieldFilter("is_beta_tester", "==", True)).stream():
1444
+ data = doc.to_dict()
1445
+ if not is_test_email(data.get("email", "")):
1446
+ all_beta_users.append(data)
1447
+
1448
+ total_beta_testers = len(all_beta_users)
1449
+ active_testers = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "active")
1450
+ pending_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "pending_feedback")
1451
+ completed_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "completed")
1452
+
1453
+ # Filter feedback by non-test users
1454
+ all_feedback = []
1455
+ for doc in user_service.db.collection("beta_feedback").stream():
1456
+ data = doc.to_dict()
1457
+ if not is_test_email(data.get("user_email", "")):
1458
+ all_feedback.append(data)
1459
+ feedback_docs = all_feedback
1460
+ else:
1461
+ # Use efficient aggregation queries when including test data
1462
+ def get_count(query) -> int:
1463
+ agg_query = aggregation.AggregationQuery(query)
1464
+ agg_query.count(alias="count")
1465
+ results = agg_query.get()
1466
+ return results[0][0].value if results else 0
1467
+
1468
+ total_beta_testers = get_count(
1469
+ users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
1470
+ )
1471
+ active_testers = get_count(
1472
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
1473
+ )
1474
+ pending_feedback = get_count(
1475
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
1476
+ )
1477
+ completed_feedback = get_count(
1478
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
1479
+ )
1480
+ feedback_docs = [doc.to_dict() for doc in user_service.db.collection("beta_feedback").stream()]
1481
+
1482
+ # Calculate average ratings from feedback
1483
+ avg_overall = 0
1484
+ avg_ease = 0
1485
+ avg_accuracy = 0
1486
+ avg_correction = 0
1487
+
1488
+ if feedback_docs:
1489
+ total = len(feedback_docs)
1490
+ for data in feedback_docs:
1491
+ avg_overall += data.get("overall_rating", 0)
1492
+ avg_ease += data.get("ease_of_use_rating", 0)
1493
+ avg_accuracy += data.get("lyrics_accuracy_rating", 0)
1494
+ avg_correction += data.get("correction_experience_rating", 0)
1495
+
1496
+ avg_overall = round(avg_overall / total, 2)
1497
+ avg_ease = round(avg_ease / total, 2)
1498
+ avg_accuracy = round(avg_accuracy / total, 2)
1499
+ avg_correction = round(avg_correction / total, 2)
1500
+
1501
+ return {
1502
+ "total_beta_testers": total_beta_testers,
1503
+ "active_testers": active_testers,
1504
+ "pending_feedback": pending_feedback,
1505
+ "completed_feedback": completed_feedback,
1506
+ "total_feedback_submissions": len(feedback_docs),
1507
+ "average_ratings": {
1508
+ "overall": avg_overall,
1509
+ "ease_of_use": avg_ease,
1510
+ "lyrics_accuracy": avg_accuracy,
1511
+ "correction_experience": avg_correction,
1512
+ },
1513
+ }