karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) 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 +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -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 +405 -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 +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1014 @@
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.api.dependencies import require_admin
36
+ from backend.services.auth_service import UserType
37
+
38
+
39
+ logger = logging.getLogger(__name__)
40
+ router = APIRouter(prefix="/users", tags=["users"])
41
+
42
+
43
+ # =============================================================================
44
+ # Request/Response Models
45
+ # =============================================================================
46
+
47
+ class CreateCheckoutRequest(BaseModel):
48
+ """Request to create a Stripe checkout session."""
49
+ package_id: str
50
+ email: EmailStr
51
+
52
+
53
+ class CreateCheckoutResponse(BaseModel):
54
+ """Response with checkout URL."""
55
+ status: str
56
+ checkout_url: str
57
+ message: str
58
+
59
+
60
+ class CreditPackage(BaseModel):
61
+ """Credit package information."""
62
+ id: str
63
+ credits: int
64
+ price_cents: int
65
+ name: str
66
+ description: str
67
+
68
+
69
+ class CreditPackagesResponse(BaseModel):
70
+ """Response listing available credit packages."""
71
+ packages: list[CreditPackage]
72
+
73
+
74
+ class UserProfileResponse(BaseModel):
75
+ """Response with user profile."""
76
+ user: UserPublic
77
+ has_session: bool
78
+
79
+
80
+ class LogoutResponse(BaseModel):
81
+ """Response after logout."""
82
+ status: str
83
+ message: str
84
+
85
+
86
+ # =============================================================================
87
+ # Magic Link Authentication
88
+ # =============================================================================
89
+
90
+ @router.post("/auth/magic-link", response_model=SendMagicLinkResponse)
91
+ async def send_magic_link(
92
+ request: SendMagicLinkRequest,
93
+ http_request: Request,
94
+ user_service: UserService = Depends(get_user_service),
95
+ email_service: EmailService = Depends(get_email_service),
96
+ ):
97
+ """
98
+ Send a magic link email for passwordless authentication.
99
+
100
+ The user will receive an email with a link that logs them in.
101
+ Links expire after 15 minutes and can only be used once.
102
+ """
103
+ # Check if email service is configured
104
+ if not email_service.is_configured():
105
+ logger.error("Email service not configured - cannot send magic links")
106
+ raise HTTPException(
107
+ status_code=503,
108
+ detail="Email service is not available. Please contact support."
109
+ )
110
+
111
+ email = request.email.lower()
112
+
113
+ # Get client info for security logging
114
+ ip_address = http_request.client.host if http_request.client else None
115
+ user_agent = http_request.headers.get("user-agent")
116
+
117
+ # Create magic link token
118
+ magic_link = user_service.create_magic_link(
119
+ email,
120
+ ip_address=ip_address,
121
+ user_agent=user_agent
122
+ )
123
+
124
+ # Send email
125
+ sent = email_service.send_magic_link(email, magic_link.token)
126
+
127
+ if not sent:
128
+ logger.error(f"Failed to send magic link email to {email}")
129
+ # Don't reveal failure to prevent email enumeration
130
+ # Still return success
131
+
132
+ return SendMagicLinkResponse(
133
+ status="success",
134
+ message="If this email is registered, you will receive a sign-in link shortly."
135
+ )
136
+
137
+
138
+ @router.get("/auth/verify", response_model=VerifyMagicLinkResponse)
139
+ async def verify_magic_link(
140
+ token: str,
141
+ http_request: Request,
142
+ user_service: UserService = Depends(get_user_service),
143
+ email_service: EmailService = Depends(get_email_service),
144
+ ):
145
+ """
146
+ Verify a magic link token and create a session.
147
+
148
+ Returns a session token that should be stored and used for subsequent requests.
149
+ """
150
+ # Get client info
151
+ ip_address = http_request.client.host if http_request.client else None
152
+ user_agent = http_request.headers.get("user-agent")
153
+
154
+ # Check if this is a first login BEFORE verification (which sets last_login_at)
155
+ # We need to get the user's state before verify_magic_link updates it
156
+ from backend.services.user_service import MAGIC_LINKS_COLLECTION
157
+ magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
158
+ is_first_login = False
159
+ if magic_link_doc.exists:
160
+ magic_link_data = magic_link_doc.to_dict()
161
+ pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
162
+ if pre_verify_user:
163
+ is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
164
+
165
+ # Verify the magic link
166
+ success, user, message = user_service.verify_magic_link(token)
167
+
168
+ if not success or not user:
169
+ raise HTTPException(status_code=401, detail=message)
170
+
171
+ # Create session
172
+ session = user_service.create_session(
173
+ user.email,
174
+ ip_address=ip_address,
175
+ user_agent=user_agent
176
+ )
177
+
178
+ # Send welcome email to first-time users
179
+ if is_first_login:
180
+ email_service.send_welcome_email(user.email, user.credits)
181
+
182
+ # Return user info
183
+ user_public = UserPublic(
184
+ email=user.email,
185
+ role=user.role,
186
+ credits=user.credits,
187
+ display_name=user.display_name,
188
+ total_jobs_created=user.total_jobs_created,
189
+ total_jobs_completed=user.total_jobs_completed,
190
+ )
191
+
192
+ return VerifyMagicLinkResponse(
193
+ status="success",
194
+ session_token=session.token,
195
+ user=user_public,
196
+ message="Successfully signed in"
197
+ )
198
+
199
+
200
+ @router.post("/auth/logout", response_model=LogoutResponse)
201
+ async def logout(
202
+ authorization: Optional[str] = Header(None),
203
+ user_service: UserService = Depends(get_user_service),
204
+ ):
205
+ """
206
+ Logout and invalidate the current session.
207
+ """
208
+ if not authorization:
209
+ return LogoutResponse(status="success", message="Already logged out")
210
+
211
+ # Extract token from "Bearer <token>"
212
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
213
+
214
+ user_service.revoke_session(token)
215
+
216
+ return LogoutResponse(status="success", message="Successfully logged out")
217
+
218
+
219
+ # =============================================================================
220
+ # User Profile
221
+ # =============================================================================
222
+
223
+ @router.get("/me", response_model=UserProfileResponse)
224
+ async def get_current_user(
225
+ authorization: Optional[str] = Header(None),
226
+ user_service: UserService = Depends(get_user_service),
227
+ ):
228
+ """
229
+ Get the current user's profile.
230
+
231
+ Requires a valid session token or admin token.
232
+ """
233
+ from backend.services.auth_service import get_auth_service
234
+
235
+ if not authorization:
236
+ raise HTTPException(status_code=401, detail="Not authenticated")
237
+
238
+ # Extract token
239
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
240
+
241
+ # First try auth service (handles admin tokens and auth_tokens)
242
+ auth_service = get_auth_service()
243
+ auth_result = auth_service.validate_token_full(token)
244
+
245
+ if auth_result.is_valid and auth_result.user_email:
246
+ # Get or create user record for the authenticated email
247
+ user = user_service.get_or_create_user(auth_result.user_email)
248
+ user_public = UserPublic(
249
+ email=user.email,
250
+ role=user.role if not auth_result.is_admin else UserRole.ADMIN,
251
+ credits=user.credits,
252
+ display_name=user.display_name,
253
+ total_jobs_created=user.total_jobs_created,
254
+ total_jobs_completed=user.total_jobs_completed,
255
+ )
256
+ return UserProfileResponse(user=user_public, has_session=True)
257
+
258
+ # Fall back to session validation (for magic link sessions)
259
+ valid, user, message = user_service.validate_session(token)
260
+
261
+ if not valid or not user:
262
+ raise HTTPException(status_code=401, detail=message)
263
+
264
+ user_public = UserPublic(
265
+ email=user.email,
266
+ role=user.role,
267
+ credits=user.credits,
268
+ display_name=user.display_name,
269
+ total_jobs_created=user.total_jobs_created,
270
+ total_jobs_completed=user.total_jobs_completed,
271
+ )
272
+
273
+ return UserProfileResponse(user=user_public, has_session=True)
274
+
275
+
276
+ # =============================================================================
277
+ # Credit Packages & Checkout
278
+ # =============================================================================
279
+
280
+ @router.get("/credits/packages", response_model=CreditPackagesResponse)
281
+ async def list_credit_packages():
282
+ """
283
+ List available credit packages for purchase.
284
+
285
+ No authentication required - this is public information.
286
+ """
287
+ packages = [
288
+ CreditPackage(
289
+ id=pkg_id,
290
+ credits=pkg["credits"],
291
+ price_cents=pkg["price_cents"],
292
+ name=pkg["name"],
293
+ description=pkg["description"],
294
+ )
295
+ for pkg_id, pkg in CREDIT_PACKAGES.items()
296
+ ]
297
+
298
+ return CreditPackagesResponse(packages=packages)
299
+
300
+
301
+ @router.post("/credits/checkout", response_model=CreateCheckoutResponse)
302
+ async def create_checkout(
303
+ request: CreateCheckoutRequest,
304
+ stripe_service: StripeService = Depends(get_stripe_service),
305
+ ):
306
+ """
307
+ Create a Stripe checkout session for purchasing credits.
308
+
309
+ Returns a URL to redirect the user to Stripe's hosted checkout page.
310
+ No authentication required - email is provided in the request.
311
+ """
312
+ if not stripe_service.is_configured():
313
+ raise HTTPException(status_code=503, detail="Payment processing is not available")
314
+
315
+ success, checkout_url, message = stripe_service.create_checkout_session(
316
+ package_id=request.package_id,
317
+ user_email=request.email,
318
+ )
319
+
320
+ if not success or not checkout_url:
321
+ raise HTTPException(status_code=400, detail=message)
322
+
323
+ return CreateCheckoutResponse(
324
+ status="success",
325
+ checkout_url=checkout_url,
326
+ message=message,
327
+ )
328
+
329
+
330
+ # =============================================================================
331
+ # Stripe Webhooks
332
+ # =============================================================================
333
+
334
+ @router.post("/webhooks/stripe")
335
+ async def stripe_webhook(
336
+ request: Request,
337
+ stripe_signature: str = Header(None, alias="stripe-signature"),
338
+ stripe_service: StripeService = Depends(get_stripe_service),
339
+ user_service: UserService = Depends(get_user_service),
340
+ email_service: EmailService = Depends(get_email_service),
341
+ ):
342
+ """
343
+ Handle Stripe webhook events.
344
+
345
+ This endpoint receives events from Stripe about payment status.
346
+ It verifies the webhook signature and processes the event.
347
+ """
348
+ if not stripe_signature:
349
+ raise HTTPException(status_code=400, detail="Missing Stripe signature")
350
+
351
+ # Get raw body for signature verification
352
+ payload = await request.body()
353
+
354
+ # Verify signature
355
+ valid, event, message = stripe_service.verify_webhook_signature(payload, stripe_signature)
356
+
357
+ if not valid:
358
+ logger.warning(f"Invalid Stripe webhook signature: {message}")
359
+ raise HTTPException(status_code=400, detail=message)
360
+
361
+ # Handle the event
362
+ event_type = event.get("type")
363
+ logger.info(f"Received Stripe webhook: {event_type}")
364
+
365
+ if event_type == "checkout.session.completed":
366
+ session = event["data"]["object"]
367
+ session_id = session.get("id")
368
+
369
+ # Idempotency check: Skip if this session was already processed
370
+ if session_id and user_service.is_stripe_session_processed(session_id):
371
+ logger.info(f"Skipping already processed session: {session_id}")
372
+ return {"status": "received", "type": event_type, "note": "already_processed"}
373
+
374
+ # Process the completed checkout
375
+ success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
376
+
377
+ if success and user_email and credits > 0:
378
+ # Add credits to user account
379
+ ok, new_balance, credit_msg = user_service.add_credits(
380
+ email=user_email,
381
+ amount=credits,
382
+ reason="stripe_purchase",
383
+ stripe_session_id=session_id,
384
+ )
385
+
386
+ if ok:
387
+ # Send confirmation email
388
+ email_service.send_credits_added(user_email, credits, new_balance)
389
+ logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
390
+ else:
391
+ logger.error(f"Failed to add credits: {credit_msg}")
392
+
393
+ elif event_type == "checkout.session.expired":
394
+ logger.info(f"Checkout session expired: {event['data']['object'].get('id')}")
395
+
396
+ elif event_type == "payment_intent.payment_failed":
397
+ logger.warning(f"Payment failed: {event['data']['object'].get('id')}")
398
+
399
+ # Return 200 to acknowledge receipt (Stripe will retry on non-2xx)
400
+ return {"status": "received", "type": event_type}
401
+
402
+
403
+ # =============================================================================
404
+ # Beta Tester Program
405
+ # =============================================================================
406
+
407
+ BETA_TESTER_FREE_CREDITS = 1 # Number of free credits for beta testers
408
+
409
+
410
+ @router.post("/beta/enroll", response_model=BetaTesterEnrollResponse)
411
+ async def enroll_beta_tester(
412
+ request: BetaTesterEnrollRequest,
413
+ http_request: Request,
414
+ user_service: UserService = Depends(get_user_service),
415
+ email_service: EmailService = Depends(get_email_service),
416
+ ):
417
+ """
418
+ Enroll as a beta tester to receive free karaoke credits.
419
+
420
+ Requirements:
421
+ - Accept that there may be work to review/correct lyrics
422
+ - Promise to provide feedback after using the tool
423
+
424
+ Returns free credits and optionally a session token for new users.
425
+ """
426
+ # Check if email service is configured
427
+ if not email_service.is_configured():
428
+ logger.error("Email service not configured - cannot send beta welcome emails")
429
+ raise HTTPException(
430
+ status_code=503,
431
+ detail="Email service is not available. Please contact support."
432
+ )
433
+
434
+ email = request.email.lower()
435
+
436
+ # Validate acceptance
437
+ if not request.accept_corrections_work:
438
+ raise HTTPException(
439
+ status_code=400,
440
+ detail="You must accept that you may need to review/correct lyrics"
441
+ )
442
+
443
+ if len(request.promise_text.strip()) < 10:
444
+ raise HTTPException(
445
+ status_code=400,
446
+ detail="Please write a sentence confirming your promise to provide feedback"
447
+ )
448
+
449
+ # Get or create user
450
+ user = user_service.get_or_create_user(email)
451
+
452
+ # Check if already enrolled as beta tester
453
+ if user.is_beta_tester:
454
+ raise HTTPException(
455
+ status_code=400,
456
+ detail="You are already enrolled in the beta program"
457
+ )
458
+
459
+ # Get client info
460
+ ip_address = http_request.client.host if http_request.client else None
461
+ user_agent = http_request.headers.get("user-agent")
462
+
463
+ # Enroll as beta tester
464
+ from datetime import datetime
465
+ user_service.update_user(
466
+ email,
467
+ is_beta_tester=True,
468
+ beta_tester_status=BetaTesterStatus.ACTIVE.value,
469
+ beta_enrolled_at=datetime.utcnow(),
470
+ beta_promise_text=request.promise_text.strip(),
471
+ )
472
+
473
+ # Add free credits
474
+ _, new_balance, _ = user_service.add_credits(
475
+ email=email,
476
+ amount=BETA_TESTER_FREE_CREDITS,
477
+ reason="beta_tester_enrollment",
478
+ )
479
+
480
+ # Create session for the user (so they can start using the service immediately)
481
+ session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
482
+
483
+ # Send welcome email
484
+ email_service.send_beta_welcome_email(email, BETA_TESTER_FREE_CREDITS)
485
+
486
+ logger.info(f"Beta tester enrolled: {email}, granted {BETA_TESTER_FREE_CREDITS} credits")
487
+
488
+ return BetaTesterEnrollResponse(
489
+ status="success",
490
+ message=f"Welcome to the beta program! You have {new_balance} free credits.",
491
+ credits_granted=BETA_TESTER_FREE_CREDITS,
492
+ session_token=session.token,
493
+ )
494
+
495
+
496
+ @router.post("/beta/feedback", response_model=BetaFeedbackResponse)
497
+ async def submit_beta_feedback(
498
+ request: BetaFeedbackRequest,
499
+ authorization: Optional[str] = Header(None),
500
+ user_service: UserService = Depends(get_user_service),
501
+ ):
502
+ """
503
+ Submit feedback as a beta tester.
504
+
505
+ Requires authentication. Updates beta tester status to completed.
506
+ May grant bonus credits for detailed feedback.
507
+ """
508
+ if not authorization:
509
+ raise HTTPException(status_code=401, detail="Authentication required")
510
+
511
+ # Extract token
512
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
513
+
514
+ # Validate session
515
+ valid, user, message = user_service.validate_session(token)
516
+ if not valid or not user:
517
+ raise HTTPException(status_code=401, detail=message)
518
+
519
+ # Check if user is a beta tester
520
+ if not user.is_beta_tester:
521
+ raise HTTPException(
522
+ status_code=400,
523
+ detail="Only beta testers can submit feedback through this endpoint"
524
+ )
525
+
526
+ # Check if already completed feedback
527
+ if user.beta_tester_status == BetaTesterStatus.COMPLETED:
528
+ raise HTTPException(
529
+ status_code=400,
530
+ detail="You have already submitted feedback. Thank you!"
531
+ )
532
+
533
+ # Save feedback to Firestore
534
+ import uuid
535
+
536
+ feedback = BetaTesterFeedback(
537
+ id=str(uuid.uuid4()),
538
+ user_email=user.email,
539
+ job_id=request.job_id,
540
+ overall_rating=request.overall_rating,
541
+ ease_of_use_rating=request.ease_of_use_rating,
542
+ lyrics_accuracy_rating=request.lyrics_accuracy_rating,
543
+ correction_experience_rating=request.correction_experience_rating,
544
+ what_went_well=request.what_went_well,
545
+ what_could_improve=request.what_could_improve,
546
+ additional_comments=request.additional_comments,
547
+ would_recommend=request.would_recommend,
548
+ would_use_again=request.would_use_again,
549
+ submitted_via="web",
550
+ )
551
+
552
+ # Save to Firestore
553
+ user_service.db.collection("beta_feedback").document(feedback.id).set(
554
+ feedback.model_dump(mode='json')
555
+ )
556
+
557
+ # Update user status
558
+ user_service.update_user(
559
+ user.email,
560
+ beta_tester_status=BetaTesterStatus.COMPLETED.value,
561
+ )
562
+
563
+ # Calculate bonus credits for detailed feedback
564
+ bonus_credits = 0
565
+ has_detailed_feedback = (
566
+ (request.what_went_well and len(request.what_went_well) > 50) or
567
+ (request.what_could_improve and len(request.what_could_improve) > 50) or
568
+ (request.additional_comments and len(request.additional_comments) > 50)
569
+ )
570
+
571
+ if has_detailed_feedback:
572
+ # Grant bonus credit for detailed feedback
573
+ bonus_credits = 1
574
+ user_service.add_credits(
575
+ email=user.email,
576
+ amount=bonus_credits,
577
+ reason="beta_feedback_bonus",
578
+ )
579
+
580
+ logger.info(f"Beta feedback received from {user.email}, bonus: {bonus_credits}")
581
+
582
+ return BetaFeedbackResponse(
583
+ status="success",
584
+ message="Thank you for your feedback!" + (
585
+ f" You earned {bonus_credits} bonus credit for your detailed response!"
586
+ if bonus_credits > 0 else ""
587
+ ),
588
+ bonus_credits=bonus_credits,
589
+ )
590
+
591
+
592
+ @router.get("/beta/feedback-form")
593
+ async def get_feedback_form_data(
594
+ authorization: Optional[str] = Header(None),
595
+ user_service: UserService = Depends(get_user_service),
596
+ ):
597
+ """
598
+ Get data needed to show the feedback form.
599
+
600
+ Returns whether the user needs to submit feedback and any job context.
601
+ """
602
+ if not authorization:
603
+ raise HTTPException(status_code=401, detail="Authentication required")
604
+
605
+ token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
606
+ valid, user, message = user_service.validate_session(token)
607
+
608
+ if not valid or not user:
609
+ raise HTTPException(status_code=401, detail=message)
610
+
611
+ return {
612
+ "is_beta_tester": user.is_beta_tester,
613
+ "beta_status": user.beta_tester_status,
614
+ "needs_feedback": (
615
+ user.is_beta_tester and
616
+ user.beta_tester_status == BetaTesterStatus.PENDING_FEEDBACK.value
617
+ ),
618
+ "can_submit_feedback": (
619
+ user.is_beta_tester and
620
+ user.beta_tester_status != BetaTesterStatus.COMPLETED.value
621
+ ),
622
+ }
623
+
624
+
625
+ # =============================================================================
626
+ # Admin Endpoints
627
+ # =============================================================================
628
+
629
+ class UserListResponsePaginated(BaseModel):
630
+ """Paginated response for user list."""
631
+ users: list[UserPublic]
632
+ total: int
633
+ offset: int
634
+ limit: int
635
+ has_more: bool
636
+
637
+
638
+ class UserDetailResponse(BaseModel):
639
+ """Detailed user information for admin view."""
640
+ email: str
641
+ role: UserRole
642
+ credits: int
643
+ display_name: Optional[str] = None
644
+ is_active: bool = True
645
+ email_verified: bool = False
646
+ created_at: Optional[str] = None
647
+ updated_at: Optional[str] = None
648
+ last_login_at: Optional[str] = None
649
+ total_jobs_created: int = 0
650
+ total_jobs_completed: int = 0
651
+ is_beta_tester: bool = False
652
+ beta_tester_status: Optional[str] = None
653
+ credit_transactions: list[dict] = []
654
+ recent_jobs: list[dict] = []
655
+ active_sessions_count: int = 0
656
+
657
+
658
+ @router.get("/admin/users", response_model=UserListResponsePaginated)
659
+ async def list_users(
660
+ limit: int = 50,
661
+ offset: int = 0,
662
+ search: Optional[str] = None,
663
+ sort_by: str = "created_at",
664
+ sort_order: str = "desc",
665
+ include_inactive: bool = False,
666
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
667
+ user_service: UserService = Depends(get_user_service),
668
+ ):
669
+ """
670
+ List all users with search, pagination, and sorting (admin only).
671
+
672
+ Args:
673
+ limit: Maximum users to return (default 50, max 100)
674
+ offset: Number of users to skip for pagination
675
+ search: Search by email (case-insensitive prefix match)
676
+ sort_by: Field to sort by (created_at, last_login_at, credits, email)
677
+ sort_order: Sort direction (asc, desc)
678
+ include_inactive: Include disabled users
679
+ """
680
+ from google.cloud import firestore
681
+ from google.cloud.firestore_v1 import FieldFilter
682
+
683
+ # Validate and cap limit
684
+ limit = min(limit, 100)
685
+
686
+ db = user_service.db
687
+ query = db.collection(USERS_COLLECTION)
688
+
689
+ # Filter inactive users
690
+ if not include_inactive:
691
+ query = query.where(filter=FieldFilter('is_active', '==', True))
692
+
693
+ # Search by email prefix (case-insensitive via range query)
694
+ if search:
695
+ search_lower = search.lower()
696
+ # Use range query for prefix matching
697
+ query = query.where(filter=FieldFilter('email', '>=', search_lower))
698
+ query = query.where(filter=FieldFilter('email', '<', search_lower + '\uffff'))
699
+
700
+ # Sorting
701
+ direction = firestore.Query.DESCENDING if sort_order == "desc" else firestore.Query.ASCENDING
702
+ if sort_by in ["created_at", "last_login_at", "credits", "email"]:
703
+ query = query.order_by(sort_by, direction=direction)
704
+ else:
705
+ query = query.order_by("created_at", direction=direction)
706
+
707
+ # Get total count (without pagination) for has_more calculation
708
+ # Note: This is expensive for large datasets, consider caching
709
+ all_docs = list(query.stream())
710
+ total_count = len(all_docs)
711
+
712
+ # Apply pagination manually (Firestore doesn't support offset well)
713
+ paginated_docs = all_docs[offset:offset + limit]
714
+
715
+ users_public = []
716
+ for doc in paginated_docs:
717
+ data = doc.to_dict()
718
+ users_public.append(UserPublic(
719
+ email=data.get("email", ""),
720
+ role=data.get("role", UserRole.USER),
721
+ credits=data.get("credits", 0),
722
+ display_name=data.get("display_name"),
723
+ total_jobs_created=data.get("total_jobs_created", 0),
724
+ total_jobs_completed=data.get("total_jobs_completed", 0),
725
+ ))
726
+
727
+ return UserListResponsePaginated(
728
+ users=users_public,
729
+ total=total_count,
730
+ offset=offset,
731
+ limit=limit,
732
+ has_more=(offset + limit) < total_count,
733
+ )
734
+
735
+
736
+ @router.get("/admin/users/{email}/detail", response_model=UserDetailResponse)
737
+ async def get_user_detail(
738
+ email: str,
739
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
740
+ user_service: UserService = Depends(get_user_service),
741
+ ):
742
+ """
743
+ Get detailed user information including credit history and recent jobs (admin only).
744
+ """
745
+ from google.cloud import firestore
746
+ from google.cloud.firestore_v1 import FieldFilter
747
+ from urllib.parse import unquote
748
+
749
+ # URL decode the email (handles @ and other special chars)
750
+ email = unquote(email).lower()
751
+
752
+ user = user_service.get_user(email)
753
+ if not user:
754
+ raise HTTPException(status_code=404, detail="User not found")
755
+
756
+ db = user_service.db
757
+
758
+ # Get recent jobs for this user
759
+ jobs_query = db.collection("jobs").where(
760
+ filter=FieldFilter("user_email", "==", email)
761
+ ).order_by("created_at", direction=firestore.Query.DESCENDING).limit(10)
762
+
763
+ recent_jobs = []
764
+ for job_doc in jobs_query.stream():
765
+ job_data = job_doc.to_dict()
766
+ created_at = job_data.get("created_at")
767
+ # Handle both datetime objects and ISO strings
768
+ if created_at:
769
+ created_at_str = created_at.isoformat() if hasattr(created_at, 'isoformat') else str(created_at)
770
+ else:
771
+ created_at_str = None
772
+ recent_jobs.append({
773
+ "job_id": job_data.get("job_id"),
774
+ "status": job_data.get("status"),
775
+ "artist": job_data.get("artist"),
776
+ "title": job_data.get("title"),
777
+ "created_at": created_at_str,
778
+ })
779
+
780
+ # Count active sessions
781
+ sessions_query = db.collection("sessions").where(
782
+ filter=FieldFilter("user_email", "==", email)
783
+ ).where(
784
+ filter=FieldFilter("is_active", "==", True)
785
+ )
786
+ active_sessions_count = sum(1 for _ in sessions_query.stream())
787
+
788
+ # Format credit transactions
789
+ credit_transactions = []
790
+ for txn in user.credit_transactions[-20:]: # Last 20 transactions
791
+ if hasattr(txn, 'model_dump'):
792
+ credit_transactions.append(txn.model_dump(mode='json'))
793
+ elif isinstance(txn, dict):
794
+ credit_transactions.append(txn)
795
+
796
+ return UserDetailResponse(
797
+ email=user.email,
798
+ role=user.role,
799
+ credits=user.credits,
800
+ display_name=user.display_name,
801
+ is_active=user.is_active,
802
+ email_verified=user.email_verified,
803
+ created_at=user.created_at.isoformat() if user.created_at else None,
804
+ updated_at=user.updated_at.isoformat() if user.updated_at else None,
805
+ last_login_at=user.last_login_at.isoformat() if user.last_login_at else None,
806
+ total_jobs_created=user.total_jobs_created,
807
+ total_jobs_completed=user.total_jobs_completed,
808
+ is_beta_tester=user.is_beta_tester,
809
+ beta_tester_status=user.beta_tester_status,
810
+ credit_transactions=credit_transactions,
811
+ recent_jobs=recent_jobs,
812
+ active_sessions_count=active_sessions_count,
813
+ )
814
+
815
+
816
+ @router.post("/admin/credits", response_model=AddCreditsResponse)
817
+ async def add_credits_to_user(
818
+ request: AddCreditsRequest,
819
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
820
+ user_service: UserService = Depends(get_user_service),
821
+ email_service: EmailService = Depends(get_email_service),
822
+ ):
823
+ """
824
+ Add credits to a user's account (admin only).
825
+
826
+ Use this to grant free credits to users, e.g., for beta testers or promotions.
827
+ """
828
+ admin_token, _, _ = auth_data
829
+
830
+ # TODO: Enhance auth system to track admin email identity for better audit trails.
831
+ # Current token-based admin auth doesn't include email identity.
832
+ # For now, we log the token prefix for traceability.
833
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
834
+
835
+ success, new_balance, message = user_service.add_credits(
836
+ email=request.email,
837
+ amount=request.amount,
838
+ reason=request.reason,
839
+ admin_email=admin_id,
840
+ )
841
+
842
+ if not success:
843
+ raise HTTPException(status_code=400, detail=message)
844
+
845
+ # Send notification email
846
+ if request.amount > 0:
847
+ email_service.send_credits_added(request.email, request.amount, new_balance)
848
+
849
+ return AddCreditsResponse(
850
+ status="success",
851
+ email=request.email,
852
+ credits_added=request.amount,
853
+ new_balance=new_balance,
854
+ message=message,
855
+ )
856
+
857
+
858
+ @router.post("/admin/users/{email}/disable")
859
+ async def disable_user(
860
+ email: str,
861
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
862
+ user_service: UserService = Depends(get_user_service),
863
+ ):
864
+ """
865
+ Disable a user account (admin only).
866
+ """
867
+ admin_token, _, _ = auth_data
868
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
869
+
870
+ success = user_service.disable_user(email, admin_email=admin_id)
871
+
872
+ if not success:
873
+ raise HTTPException(status_code=404, detail="User not found")
874
+
875
+ return {"status": "success", "message": f"User {email} has been disabled"}
876
+
877
+
878
+ @router.post("/admin/users/{email}/enable")
879
+ async def enable_user(
880
+ email: str,
881
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
882
+ user_service: UserService = Depends(get_user_service),
883
+ ):
884
+ """
885
+ Enable a user account (admin only).
886
+ """
887
+ admin_token, _, _ = auth_data
888
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
889
+
890
+ success = user_service.enable_user(email, admin_email=admin_id)
891
+
892
+ if not success:
893
+ raise HTTPException(status_code=404, detail="User not found")
894
+
895
+ return {"status": "success", "message": f"User {email} has been enabled"}
896
+
897
+
898
+ @router.post("/admin/users/{email}/role")
899
+ async def set_user_role(
900
+ email: str,
901
+ role: UserRole,
902
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
903
+ user_service: UserService = Depends(get_user_service),
904
+ ):
905
+ """
906
+ Set a user's role (admin only).
907
+ """
908
+ admin_token, _, _ = auth_data
909
+ admin_id = f"admin:{admin_token[:8]}..." if admin_token else "admin:unknown"
910
+
911
+ success = user_service.set_user_role(email, role, admin_email=admin_id)
912
+
913
+ if not success:
914
+ raise HTTPException(status_code=404, detail="User not found")
915
+
916
+ return {"status": "success", "message": f"User {email} role set to {role.value}"}
917
+
918
+
919
+ @router.get("/admin/beta/feedback")
920
+ async def list_beta_feedback(
921
+ limit: int = 50,
922
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
923
+ user_service: UserService = Depends(get_user_service),
924
+ ):
925
+ """
926
+ List all beta tester feedback (admin only).
927
+ """
928
+ from google.cloud import firestore
929
+
930
+ query = user_service.db.collection("beta_feedback")
931
+ query = query.order_by("created_at", direction=firestore.Query.DESCENDING)
932
+ query = query.limit(limit)
933
+
934
+ docs = query.stream()
935
+ feedback_list = [doc.to_dict() for doc in docs]
936
+
937
+ return {
938
+ "feedback": feedback_list,
939
+ "total": len(feedback_list),
940
+ }
941
+
942
+
943
+ @router.get("/admin/beta/stats")
944
+ async def get_beta_stats(
945
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
946
+ user_service: UserService = Depends(get_user_service),
947
+ ):
948
+ """
949
+ Get beta tester program statistics (admin only).
950
+ """
951
+ from google.cloud.firestore_v1 import FieldFilter
952
+ from google.cloud.firestore_v1 import aggregation
953
+
954
+ # Count beta testers by status using efficient aggregation queries
955
+ users_collection = user_service.db.collection(USERS_COLLECTION)
956
+
957
+ # Helper function to get count using aggregation
958
+ def get_count(query) -> int:
959
+ agg_query = aggregation.AggregationQuery(query)
960
+ agg_query.count(alias="count")
961
+ results = agg_query.get()
962
+ return results[0][0].value if results else 0
963
+
964
+ total_beta_testers = get_count(
965
+ users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
966
+ )
967
+
968
+ active_testers = get_count(
969
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
970
+ )
971
+
972
+ pending_feedback = get_count(
973
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
974
+ )
975
+
976
+ completed_feedback = get_count(
977
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
978
+ )
979
+
980
+ # Get average ratings from feedback
981
+ feedback_docs = list(user_service.db.collection("beta_feedback").stream())
982
+
983
+ avg_overall = 0
984
+ avg_ease = 0
985
+ avg_accuracy = 0
986
+ avg_correction = 0
987
+
988
+ if feedback_docs:
989
+ total = len(feedback_docs)
990
+ for doc in feedback_docs:
991
+ data = doc.to_dict()
992
+ avg_overall += data.get("overall_rating", 0)
993
+ avg_ease += data.get("ease_of_use_rating", 0)
994
+ avg_accuracy += data.get("lyrics_accuracy_rating", 0)
995
+ avg_correction += data.get("correction_experience_rating", 0)
996
+
997
+ avg_overall = round(avg_overall / total, 2)
998
+ avg_ease = round(avg_ease / total, 2)
999
+ avg_accuracy = round(avg_accuracy / total, 2)
1000
+ avg_correction = round(avg_correction / total, 2)
1001
+
1002
+ return {
1003
+ "total_beta_testers": total_beta_testers,
1004
+ "active_testers": active_testers,
1005
+ "pending_feedback": pending_feedback,
1006
+ "completed_feedback": completed_feedback,
1007
+ "total_feedback_submissions": len(feedback_docs),
1008
+ "average_ratings": {
1009
+ "overall": avg_overall,
1010
+ "ease_of_use": avg_ease,
1011
+ "lyrics_accuracy": avg_accuracy,
1012
+ "correction_experience": avg_correction,
1013
+ },
1014
+ }