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,721 @@
1
+ """
2
+ User service for authentication, credits, and user management.
3
+
4
+ Handles:
5
+ - Magic link authentication (send, verify)
6
+ - Session management
7
+ - Credit operations (add, deduct, refund)
8
+ - User CRUD operations
9
+ """
10
+ import logging
11
+ import secrets
12
+ import uuid
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Optional, List, Tuple
15
+
16
+ from google.cloud import firestore
17
+ from google.cloud.firestore_v1 import FieldFilter
18
+ from google.cloud.firestore_v1 import Increment
19
+
20
+ from backend.config import get_settings
21
+ from backend.models.user import (
22
+ User,
23
+ UserRole,
24
+ UserPublic,
25
+ MagicLinkToken,
26
+ Session,
27
+ CreditTransaction,
28
+ )
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # Collection names
35
+ USERS_COLLECTION = "gen_users"
36
+ MAGIC_LINKS_COLLECTION = "magic_links"
37
+ SESSIONS_COLLECTION = "sessions"
38
+ PROCESSED_STRIPE_SESSIONS_COLLECTION = "processed_stripe_sessions"
39
+
40
+ # Token/session configuration
41
+ MAGIC_LINK_EXPIRY_MINUTES = 15
42
+ SESSION_EXPIRY_DAYS = 7
43
+ SESSION_ABSOLUTE_EXPIRY_DAYS = 30
44
+ MAX_CREDIT_TRANSACTIONS = 100 # Keep last N transactions
45
+
46
+
47
+ class UserService:
48
+ """Service for user management and authentication."""
49
+
50
+ # Number of free credits granted to new users
51
+ NEW_USER_FREE_CREDITS = 1
52
+
53
+ def __init__(self):
54
+ """Initialize user service with Firestore client."""
55
+ self.settings = get_settings()
56
+ self.db = firestore.Client(project=self.settings.google_cloud_project)
57
+
58
+ # =========================================================================
59
+ # User CRUD Operations
60
+ # =========================================================================
61
+
62
+ def get_user(self, email: str) -> Optional[User]:
63
+ """Get a user by email."""
64
+ try:
65
+ doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
66
+ doc = doc_ref.get()
67
+
68
+ if not doc.exists:
69
+ return None
70
+
71
+ return User(**doc.to_dict())
72
+ except Exception:
73
+ logger.exception(f"Error getting user {email}")
74
+ return None
75
+
76
+ def get_or_create_user(self, email: str) -> User:
77
+ """
78
+ Get existing user or create a new one.
79
+
80
+ New users receive a welcome credit to try the service.
81
+ """
82
+ email = email.lower()
83
+ user = self.get_user(email)
84
+
85
+ if user:
86
+ return user
87
+
88
+ # Create new user with welcome credit
89
+ welcome_credit = self.NEW_USER_FREE_CREDITS
90
+ welcome_transaction = CreditTransaction(
91
+ id=str(uuid.uuid4()),
92
+ amount=welcome_credit,
93
+ reason="welcome_credit",
94
+ )
95
+
96
+ user = User(
97
+ email=email,
98
+ credits=welcome_credit,
99
+ credit_transactions=[welcome_transaction],
100
+ )
101
+ self._save_user(user)
102
+ logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s)")
103
+ return user
104
+
105
+ def _save_user(self, user: User) -> None:
106
+ """Save user to Firestore."""
107
+ try:
108
+ user.updated_at = datetime.utcnow()
109
+ doc_ref = self.db.collection(USERS_COLLECTION).document(user.email.lower())
110
+ doc_ref.set(user.model_dump(mode='json'))
111
+ except Exception:
112
+ logger.exception(f"Error saving user {user.email}")
113
+ raise
114
+
115
+ def update_user(self, email: str, **updates) -> Optional[User]:
116
+ """Update user fields."""
117
+ try:
118
+ updates['updated_at'] = datetime.utcnow()
119
+ doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
120
+ doc_ref.update(updates)
121
+ return self.get_user(email)
122
+ except Exception:
123
+ logger.exception(f"Error updating user {email}")
124
+ return None
125
+
126
+ def list_users(self, limit: int = 100, include_inactive: bool = False) -> List[User]:
127
+ """List all users (admin only)."""
128
+ try:
129
+ query = self.db.collection(USERS_COLLECTION)
130
+
131
+ if not include_inactive:
132
+ query = query.where(filter=FieldFilter('is_active', '==', True))
133
+
134
+ query = query.order_by('created_at', direction=firestore.Query.DESCENDING)
135
+ query = query.limit(limit)
136
+
137
+ docs = query.stream()
138
+ return [User(**doc.to_dict()) for doc in docs]
139
+ except Exception:
140
+ logger.exception("Error listing users")
141
+ return []
142
+
143
+ def get_user_public(self, email: str) -> Optional[UserPublic]:
144
+ """Get public user info."""
145
+ user = self.get_user(email)
146
+ if not user:
147
+ return None
148
+
149
+ return UserPublic(
150
+ email=user.email,
151
+ role=user.role,
152
+ credits=user.credits,
153
+ display_name=user.display_name,
154
+ total_jobs_created=user.total_jobs_created,
155
+ total_jobs_completed=user.total_jobs_completed,
156
+ )
157
+
158
+ # =========================================================================
159
+ # Magic Link Authentication
160
+ # =========================================================================
161
+
162
+ def create_magic_link(
163
+ self,
164
+ email: str,
165
+ ip_address: Optional[str] = None,
166
+ user_agent: Optional[str] = None
167
+ ) -> MagicLinkToken:
168
+ """
169
+ Create a magic link token for email authentication.
170
+
171
+ Returns the token object. The actual sending of the email
172
+ should be handled by the caller (or an email service).
173
+ """
174
+ email = email.lower()
175
+
176
+ # Ensure user exists
177
+ self.get_or_create_user(email)
178
+
179
+ # Generate secure token
180
+ token = secrets.token_urlsafe(32)
181
+
182
+ magic_link = MagicLinkToken(
183
+ token=token,
184
+ email=email,
185
+ expires_at=datetime.utcnow() + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES),
186
+ ip_address=ip_address,
187
+ user_agent=user_agent,
188
+ )
189
+
190
+ # Save to Firestore
191
+ doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
192
+ doc_ref.set(magic_link.model_dump(mode='json'))
193
+
194
+ logger.info(f"Created magic link for {email}")
195
+ return magic_link
196
+
197
+ def verify_magic_link(self, token: str) -> Tuple[bool, Optional[User], str]:
198
+ """
199
+ Verify a magic link token using a Firestore transaction to prevent race conditions.
200
+
201
+ Returns:
202
+ (success, user, message)
203
+ """
204
+ try:
205
+ doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
206
+
207
+ @firestore.transactional
208
+ def verify_in_transaction(transaction):
209
+ """Atomically verify and mark magic link as used."""
210
+ doc = doc_ref.get(transaction=transaction)
211
+
212
+ if not doc.exists:
213
+ return False, None, "Invalid or expired link"
214
+
215
+ magic_link = MagicLinkToken(**doc.to_dict())
216
+
217
+ # Check if already used
218
+ if magic_link.used:
219
+ return False, None, "This link has already been used"
220
+
221
+ # Check expiry
222
+ if datetime.utcnow() > magic_link.expires_at:
223
+ return False, None, "This link has expired"
224
+
225
+ # Mark as used atomically within transaction
226
+ transaction.update(doc_ref, {
227
+ 'used': True,
228
+ 'used_at': datetime.utcnow()
229
+ })
230
+
231
+ return True, magic_link.email, "Success"
232
+
233
+ # Execute the transaction
234
+ transaction = self.db.transaction()
235
+ success, email_or_error, message = verify_in_transaction(transaction)
236
+
237
+ if not success:
238
+ return False, None, message
239
+
240
+ # Get user and mark email as verified (outside transaction)
241
+ user = self.get_user(email_or_error)
242
+ if user:
243
+ self.update_user(
244
+ email_or_error,
245
+ email_verified=True,
246
+ last_login_at=datetime.utcnow()
247
+ )
248
+ user = self.get_user(email_or_error) # Refresh
249
+
250
+ logger.info(f"Magic link verified for {email_or_error}")
251
+ return True, user, "Successfully authenticated"
252
+
253
+ except Exception:
254
+ logger.exception("Error verifying magic link")
255
+ return False, None, "An error occurred during verification"
256
+
257
+ # =========================================================================
258
+ # Session Management
259
+ # =========================================================================
260
+
261
+ def create_session(
262
+ self,
263
+ user_email: str,
264
+ ip_address: Optional[str] = None,
265
+ user_agent: Optional[str] = None
266
+ ) -> Session:
267
+ """Create a new session for an authenticated user."""
268
+ token = secrets.token_urlsafe(32)
269
+ token_prefix = token[:12]
270
+
271
+ session = Session(
272
+ token=token,
273
+ user_email=user_email.lower(),
274
+ expires_at=datetime.utcnow() + timedelta(days=SESSION_ABSOLUTE_EXPIRY_DAYS),
275
+ ip_address=ip_address,
276
+ user_agent=user_agent,
277
+ )
278
+
279
+ # Serialize and write to Firestore
280
+ session_data = session.model_dump(mode='json')
281
+ doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
282
+ doc_ref.set(session_data)
283
+
284
+ # Verify the write succeeded by reading back
285
+ verify_doc = doc_ref.get()
286
+ if not verify_doc.exists:
287
+ logger.error(f"Session write verification FAILED for {user_email}: {token_prefix}...")
288
+ raise RuntimeError(f"Failed to persist session for {user_email}")
289
+
290
+ logger.info(f"Created and verified session for {user_email}: {token_prefix}... (expires: {session.expires_at})")
291
+
292
+ return session
293
+
294
+ def validate_session(self, token: str) -> Tuple[bool, Optional[User], str]:
295
+ """
296
+ Validate a session token.
297
+
298
+ Returns:
299
+ (valid, user, message)
300
+ """
301
+ # Log token info for debugging (only prefix for security)
302
+ token_prefix = token[:12] if token and len(token) >= 12 else token
303
+ logger.debug(f"Validating session token: {token_prefix}... (len={len(token) if token else 0})")
304
+
305
+ try:
306
+ doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
307
+ doc = doc_ref.get()
308
+
309
+ if not doc.exists:
310
+ logger.warning(f"Session not found in Firestore: {token_prefix}...")
311
+ return False, None, "Invalid session"
312
+
313
+ raw_data = doc.to_dict()
314
+ logger.debug(f"Session data found for {token_prefix}...: user_email={raw_data.get('user_email')}, is_active={raw_data.get('is_active')}")
315
+
316
+ session = Session(**raw_data)
317
+
318
+ # Check if active
319
+ if not session.is_active:
320
+ logger.warning(f"Session revoked for {token_prefix}... (user: {session.user_email})")
321
+ return False, None, "Session has been revoked"
322
+
323
+ # Use timezone-aware datetime for all comparisons
324
+ # Firestore returns timezone-aware datetimes, so we must use aware datetimes
325
+ now = datetime.now(timezone.utc)
326
+
327
+ # Normalize session datetimes to be timezone-aware for comparison
328
+ # (handles legacy naive datetimes that may exist in Firestore)
329
+ expires_at = session.expires_at
330
+ if expires_at.tzinfo is None:
331
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
332
+
333
+ last_activity = session.last_activity_at
334
+ if last_activity.tzinfo is None:
335
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
336
+
337
+ # Check expiry
338
+ if now > expires_at:
339
+ logger.warning(f"Session expired for {token_prefix}... (user: {session.user_email}, expired_at: {expires_at}, now: {now})")
340
+ return False, None, "Session has expired"
341
+
342
+ # Check inactivity (7 days)
343
+ inactivity_limit = now - timedelta(days=SESSION_EXPIRY_DAYS)
344
+ if last_activity < inactivity_limit:
345
+ logger.warning(f"Session inactive for {token_prefix}... (user: {session.user_email}, last_activity: {last_activity}, limit: {inactivity_limit})")
346
+ return False, None, "Session expired due to inactivity"
347
+
348
+ # Update last activity (use timezone-aware datetime)
349
+ doc_ref.update({'last_activity_at': now})
350
+
351
+ # Get user
352
+ user = self.get_user(session.user_email)
353
+ if not user:
354
+ logger.warning(f"User not found for session {token_prefix}...: {session.user_email}")
355
+ return False, None, "User not found"
356
+
357
+ if not user.is_active:
358
+ logger.warning(f"User account disabled for session {token_prefix}...: {session.user_email}")
359
+ return False, None, "User account is disabled"
360
+
361
+ logger.debug(f"Session valid for {token_prefix}... (user: {user.email}, credits: {user.credits})")
362
+ return True, user, "Valid session"
363
+
364
+ except Exception:
365
+ logger.exception(f"Error validating session {token_prefix}...")
366
+ return False, None, "An error occurred during validation"
367
+
368
+ def revoke_session(self, token: str) -> bool:
369
+ """Revoke a session (logout)."""
370
+ try:
371
+ doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
372
+ doc_ref.update({'is_active': False})
373
+ logger.info("Session revoked")
374
+ return True
375
+ except Exception:
376
+ logger.exception("Error revoking session")
377
+ return False
378
+
379
+ def revoke_all_sessions(self, user_email: str) -> int:
380
+ """Revoke all sessions for a user."""
381
+ try:
382
+ query = self.db.collection(SESSIONS_COLLECTION).where(
383
+ filter=FieldFilter('user_email', '==', user_email.lower())
384
+ ).where(
385
+ filter=FieldFilter('is_active', '==', True)
386
+ )
387
+
388
+ count = 0
389
+ for doc in query.stream():
390
+ doc.reference.update({'is_active': False})
391
+ count += 1
392
+
393
+ logger.info(f"Revoked {count} sessions for {user_email}")
394
+ return count
395
+ except Exception:
396
+ logger.exception("Error revoking sessions")
397
+ return 0
398
+
399
+ def list_sessions_for_user(
400
+ self,
401
+ user_email: str,
402
+ include_revoked: bool = False,
403
+ limit: int = 50
404
+ ) -> List[Session]:
405
+ """
406
+ List all sessions for a user.
407
+
408
+ Args:
409
+ user_email: User's email address
410
+ include_revoked: If True, include revoked/inactive sessions
411
+ limit: Maximum number of sessions to return
412
+
413
+ Returns:
414
+ List of Session objects, ordered by created_at descending
415
+ """
416
+ try:
417
+ query = self.db.collection(SESSIONS_COLLECTION).where(
418
+ filter=FieldFilter('user_email', '==', user_email.lower())
419
+ )
420
+
421
+ if not include_revoked:
422
+ query = query.where(filter=FieldFilter('is_active', '==', True))
423
+
424
+ query = query.order_by('created_at', direction=firestore.Query.DESCENDING)
425
+ query = query.limit(limit)
426
+
427
+ sessions = []
428
+ for doc in query.stream():
429
+ try:
430
+ sessions.append(Session(**doc.to_dict()))
431
+ except Exception as e:
432
+ logger.warning(f"Failed to parse session document: {e}")
433
+
434
+ logger.debug(f"Found {len(sessions)} sessions for {user_email}")
435
+ return sessions
436
+ except Exception:
437
+ logger.exception(f"Error listing sessions for {user_email}")
438
+ return []
439
+
440
+ # =========================================================================
441
+ # Credit Operations
442
+ # =========================================================================
443
+
444
+ def add_credits(
445
+ self,
446
+ email: str,
447
+ amount: int,
448
+ reason: str,
449
+ job_id: Optional[str] = None,
450
+ stripe_session_id: Optional[str] = None,
451
+ admin_email: Optional[str] = None
452
+ ) -> Tuple[bool, int, str]:
453
+ """
454
+ Add credits to a user account.
455
+
456
+ Returns:
457
+ (success, new_balance, message)
458
+ """
459
+ try:
460
+ # For Stripe sessions, atomically check and mark as processed
461
+ # Uses create() which fails if document exists - ensures idempotency
462
+ if stripe_session_id:
463
+ doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
464
+ try:
465
+ doc_ref.create({
466
+ 'stripe_session_id': stripe_session_id,
467
+ 'email': email.lower(),
468
+ 'amount': amount,
469
+ 'processed_at': datetime.utcnow()
470
+ })
471
+ except Exception:
472
+ # Document already exists - this session was already processed
473
+ logger.info(f"Stripe session {stripe_session_id} already processed (idempotent skip)")
474
+ return False, 0, "Session already processed"
475
+
476
+ email = email.lower()
477
+ user = self.get_or_create_user(email)
478
+
479
+ # Create transaction record
480
+ transaction = CreditTransaction(
481
+ id=str(uuid.uuid4()),
482
+ amount=amount,
483
+ reason=reason,
484
+ job_id=job_id,
485
+ stripe_session_id=stripe_session_id,
486
+ created_by=admin_email,
487
+ )
488
+
489
+ # Add to transaction history (keep last N)
490
+ transactions = user.credit_transactions[-MAX_CREDIT_TRANSACTIONS + 1:]
491
+ transactions.append(transaction)
492
+
493
+ # Update user
494
+ new_balance = user.credits + amount
495
+ self.update_user(
496
+ email,
497
+ credits=new_balance,
498
+ credit_transactions=[t.model_dump(mode='json') for t in transactions]
499
+ )
500
+
501
+ logger.info(f"Added {amount} credits to {email} ({reason}). New balance: {new_balance}")
502
+ return True, new_balance, f"Added {amount} credits"
503
+
504
+ except Exception as e:
505
+ logger.exception(f"Error adding credits to {email}")
506
+ return False, 0, f"Failed to add credits: {e}"
507
+
508
+ def deduct_credit(
509
+ self,
510
+ email: str,
511
+ job_id: str,
512
+ reason: str = "job_creation"
513
+ ) -> Tuple[bool, int, str]:
514
+ """
515
+ Deduct one credit from a user account.
516
+
517
+ Uses Firestore transaction to prevent race conditions.
518
+
519
+ Returns:
520
+ (success, remaining_credits, message)
521
+ """
522
+ try:
523
+ email = email.lower()
524
+ doc_ref = self.db.collection(USERS_COLLECTION).document(email)
525
+
526
+ @firestore.transactional
527
+ def deduct_in_transaction(transaction):
528
+ """Atomically check and deduct credit."""
529
+ doc = doc_ref.get(transaction=transaction)
530
+
531
+ if not doc.exists:
532
+ return False, 0, "User not found"
533
+
534
+ user_data = doc.to_dict()
535
+ current_credits = user_data.get('credits', 0)
536
+
537
+ if current_credits <= 0:
538
+ return False, 0, "Insufficient credits"
539
+
540
+ # Create transaction record
541
+ credit_txn = CreditTransaction(
542
+ id=str(uuid.uuid4()),
543
+ amount=-1,
544
+ reason=reason,
545
+ job_id=job_id,
546
+ )
547
+
548
+ # Get existing transactions and add new one
549
+ existing_transactions = user_data.get('credit_transactions', [])
550
+ # Keep last N-1 transactions to make room for new one
551
+ transactions = existing_transactions[-(MAX_CREDIT_TRANSACTIONS - 1):]
552
+ transactions.append(credit_txn.model_dump(mode='json'))
553
+
554
+ # Calculate new values
555
+ new_balance = current_credits - 1
556
+ total_jobs = user_data.get('total_jobs_created', 0) + 1
557
+
558
+ # Update atomically within transaction
559
+ transaction.update(doc_ref, {
560
+ 'credits': new_balance,
561
+ 'credit_transactions': transactions,
562
+ 'total_jobs_created': total_jobs,
563
+ 'updated_at': datetime.utcnow()
564
+ })
565
+
566
+ return True, new_balance, f"Credit deducted. {new_balance} remaining"
567
+
568
+ # Execute the transaction
569
+ fs_transaction = self.db.transaction()
570
+ success, new_balance, message = deduct_in_transaction(fs_transaction)
571
+
572
+ if success:
573
+ logger.info(f"Deducted 1 credit from {email} for job {job_id}. Remaining: {new_balance}")
574
+
575
+ return success, new_balance, message
576
+
577
+ except Exception as e:
578
+ logger.exception(f"Error deducting credit from {email}")
579
+ return False, 0, f"Failed to deduct credit: {e}"
580
+
581
+ def refund_credit(
582
+ self,
583
+ email: str,
584
+ job_id: str,
585
+ reason: str = "job_failed"
586
+ ) -> Tuple[bool, int, str]:
587
+ """
588
+ Refund one credit to a user account (e.g., after job failure).
589
+
590
+ Returns:
591
+ (success, new_balance, message)
592
+ """
593
+ return self.add_credits(email, 1, reason, job_id=job_id)
594
+
595
+ def check_credits(self, email: str) -> int:
596
+ """Check user's credit balance."""
597
+ user = self.get_user(email)
598
+ return user.credits if user else 0
599
+
600
+ def has_credits(self, email: str) -> bool:
601
+ """Check if user has at least one credit."""
602
+ return self.check_credits(email) > 0
603
+
604
+ def is_stripe_session_processed(self, stripe_session_id: str) -> bool:
605
+ """
606
+ Check if a Stripe session ID has already been processed.
607
+
608
+ Used to ensure idempotency of webhook processing - prevents
609
+ duplicate credit additions if Stripe sends the same webhook twice.
610
+
611
+ Uses a dedicated collection for O(1) lookup instead of scanning all users.
612
+
613
+ Args:
614
+ stripe_session_id: The Stripe checkout session ID
615
+
616
+ Returns:
617
+ True if this session was already processed
618
+ """
619
+ try:
620
+ doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
621
+ doc = doc_ref.get()
622
+ if doc.exists:
623
+ logger.info(f"Stripe session {stripe_session_id} already processed")
624
+ return True
625
+ return False
626
+ except Exception:
627
+ logger.exception(f"Error checking if stripe session {stripe_session_id} was processed")
628
+ # On error, return False to allow processing (better to risk duplicate than block)
629
+ return False
630
+
631
+ def _mark_stripe_session_processed(self, stripe_session_id: str, email: str, amount: int) -> None:
632
+ """
633
+ Mark a Stripe session as processed.
634
+
635
+ Called after successfully adding credits from a Stripe webhook.
636
+
637
+ Args:
638
+ stripe_session_id: The Stripe checkout session ID
639
+ email: User email who received the credits
640
+ amount: Number of credits added
641
+ """
642
+ try:
643
+ doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
644
+ doc_ref.set({
645
+ 'stripe_session_id': stripe_session_id,
646
+ 'email': email,
647
+ 'amount': amount,
648
+ 'processed_at': datetime.utcnow()
649
+ })
650
+ except Exception:
651
+ # Log but don't fail - the credits were already added
652
+ logger.exception(f"Error marking stripe session {stripe_session_id} as processed")
653
+
654
+ # =========================================================================
655
+ # Admin Operations
656
+ # =========================================================================
657
+
658
+ def set_user_role(self, email: str, role: UserRole, admin_email: str) -> bool:
659
+ """Set a user's role (admin only)."""
660
+ try:
661
+ user = self.get_user(email)
662
+ if not user:
663
+ return False
664
+
665
+ self.update_user(email, role=role.value)
666
+ logger.info(f"Admin {admin_email} set role for {email} to {role.value}")
667
+ return True
668
+ except Exception:
669
+ logger.exception("Error setting user role")
670
+ return False
671
+
672
+ def disable_user(self, email: str, admin_email: str) -> bool:
673
+ """Disable a user account (admin only)."""
674
+ try:
675
+ self.update_user(email, is_active=False)
676
+ self.revoke_all_sessions(email)
677
+ logger.info(f"Admin {admin_email} disabled user {email}")
678
+ return True
679
+ except Exception:
680
+ logger.exception("Error disabling user")
681
+ return False
682
+
683
+ def enable_user(self, email: str, admin_email: str) -> bool:
684
+ """Enable a user account (admin only)."""
685
+ try:
686
+ self.update_user(email, is_active=True)
687
+ logger.info(f"Admin {admin_email} enabled user {email}")
688
+ return True
689
+ except Exception:
690
+ logger.exception("Error enabling user")
691
+ return False
692
+
693
+ def increment_jobs_completed(self, email: str) -> bool:
694
+ """Increment the completed jobs counter for a user using atomic increment."""
695
+ try:
696
+ # Check user exists first
697
+ if not self.get_user(email):
698
+ return False
699
+
700
+ # Use atomic increment to prevent race conditions
701
+ doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
702
+ doc_ref.update({
703
+ 'total_jobs_completed': Increment(1),
704
+ 'updated_at': datetime.utcnow()
705
+ })
706
+ return True
707
+ except Exception:
708
+ logger.exception(f"Error incrementing jobs completed for {email}")
709
+ return False
710
+
711
+
712
+ # Global instance
713
+ _user_service = None
714
+
715
+
716
+ def get_user_service() -> UserService:
717
+ """Get the global user service instance."""
718
+ global _user_service
719
+ if _user_service is None:
720
+ _user_service = UserService()
721
+ return _user_service