rairo commited on
Commit
017440b
·
verified ·
1 Parent(s): 2f8af2e

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +258 -1486
main.py CHANGED
@@ -5,7 +5,10 @@ import re
5
  import time
6
  import json
7
  import traceback
8
- import wave
 
 
 
9
  from datetime import datetime, timedelta
10
 
11
  from flask import Flask, request, jsonify, Response
@@ -13,18 +16,13 @@ from flask_cors import CORS
13
  import firebase_admin
14
  from firebase_admin import credentials, db, storage, auth
15
  from PIL import Image
16
- from io import BytesIO
17
- import requests
18
- from elevenlabs import ElevenLabs
19
- # Import and configure Google GenAI, matching the Streamlit app
20
  from google import genai
21
  from google.genai import types
22
- import stripe
23
  # -----------------------------------------------------------------------------
24
  # 1. CONFIGURATION & INITIALIZATION
25
  # -----------------------------------------------------------------------------
26
 
27
- # Initialize Flask app and CORS
28
  app = Flask(__name__)
29
  CORS(app)
30
 
@@ -38,9 +36,6 @@ try:
38
  firebase_db_url = os.environ.get("Firebase_DB")
39
  firebase_storage_bucket = os.environ.get("Firebase_Storage")
40
 
41
- if not firebase_db_url or not firebase_storage_bucket:
42
- raise ValueError("Firebase_DB and Firebase_Storage environment variables must be set.")
43
-
44
  cred = credentials.Certificate(credentials_json)
45
  firebase_admin.initialize_app(cred, {
46
  'databaseURL': firebase_db_url,
@@ -51,1602 +46,379 @@ except Exception as e:
51
  print(f"FATAL: Error initializing Firebase: {e}")
52
  exit(1)
53
 
54
- # Initialize Firebase services
55
  bucket = storage.bucket()
56
  db_ref = db.reference()
57
 
58
-
59
- # --- Google GenAI Client Initialization (as per Streamlit app) ---
60
  try:
61
  api_key = os.environ.get("Gemini")
62
  if not api_key:
63
- raise ValueError("The 'Gemini' environment variable for the API key is not set.")
64
 
65
  client = genai.Client(api_key=api_key)
66
- print("Google GenAI Client initialized successfully.")
67
  except Exception as e:
68
  print(f"FATAL: Error initializing GenAI Client: {e}")
69
  exit(1)
70
 
71
- # --- Model Constants (as per Streamlit app) ---
72
- CATEGORY_MODEL = "gemini-2.5-flash"
73
- GENERATION_MODEL = "gemini-2.0-flash-exp-image-generation"
74
- #GENERATION_MODEL = "gemini-2.5-flash-image-preview"
75
- #TTS_MODEL = "gemini-2.5-flash-preview-tts"
76
-
77
- # Stripe
78
- # --- Stripe Initialization ---
79
- STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
80
- STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET")
81
 
82
- # Price IDs from Stripe dashboard (price_xxx...) – set these in your env
83
- STRIPE_PRICE_FIXER = os.environ.get("STRIPE_PRICE_FIXER") # "The Fixer" (standard)
84
- STRIPE_PRICE_PRO = os.environ.get("STRIPE_PRICE_PRO") # "The Pro" (premium)
85
 
86
- # Frontend URLs for redirect after Checkout
87
- STRIPE_SUCCESS_URL = os.environ.get(
88
- "STRIPE_SUCCESS_URL",
89
- "https://sozofix.tech/billing/success"
90
- )
91
- STRIPE_CANCEL_URL = os.environ.get(
92
- "STRIPE_CANCEL_URL",
93
- "https://sozofix.tech/billing/cancel"
94
- )
95
-
96
- if STRIPE_SECRET_KEY:
97
- stripe.api_key = STRIPE_SECRET_KEY
98
- else:
99
- print("WARNING: STRIPE_SECRET_KEY is not set – Stripe endpoints will fail.")
100
  # -----------------------------------------------------------------------------
101
- # 2. HELPER FUNCTIONS (Adapted directly from Streamlit App & Template)
102
  # -----------------------------------------------------------------------------
103
 
104
  def verify_token(auth_header):
105
- """Verifies the Firebase ID token from the Authorization header."""
106
- if not auth_header or not auth_header.startswith('Bearer '):
107
- return None
108
  token = auth_header.split('Bearer ')[1]
109
  try:
110
  decoded_token = auth.verify_id_token(token)
111
  return decoded_token['uid']
112
- except Exception as e:
113
- print(f"Token verification failed: {e}")
114
- return None
115
 
116
  def verify_admin(auth_header):
117
- """Verifies if the user is an admin."""
118
  uid = verify_token(auth_header)
119
- if not uid:
120
- raise PermissionError('Invalid or missing user token')
121
-
122
- user_ref = db_ref.child(f'users/{uid}')
123
- user_data = user_ref.get()
124
  if not user_data or not user_data.get('is_admin', False):
125
  raise PermissionError('Admin access required')
126
  return uid
127
 
128
  def upload_to_storage(data_bytes, destination_blob_name, content_type):
129
- """Uploads a bytes object to Firebase Storage and returns its public URL."""
130
  blob = bucket.blob(destination_blob_name)
131
  blob.upload_from_string(data_bytes, content_type=content_type)
132
  blob.make_public()
133
  return blob.public_url
134
 
135
- def parse_numbered_steps(text):
136
- """Helper to parse numbered steps out of Gemini text."""
137
- text = "\n" + text
138
- steps_found = re.findall(r"\n\s*(\d+)\.\s*(.*)", text, re.MULTILINE)
139
- return [{"stepNumber": int(num), "text": desc.strip()} for num, desc in steps_found]
140
-
141
- def _convert_pcm_to_wav(pcm_data, sample_rate=24000, channels=1, sample_width=2):
142
- """Wraps raw PCM audio data in a WAV container in memory."""
143
- audio_buffer = io.BytesIO()
144
- with wave.open(audio_buffer, 'wb') as wf:
145
- wf.setnchannels(channels)
146
- wf.setsampwidth(sample_width)
147
- wf.setframerate(sample_rate)
148
- wf.writeframes(pcm_data)
149
- audio_buffer.seek(0)
150
- return audio_buffer.getvalue()
151
-
152
-
153
- #Gemini tts implementation SOTA but slow
154
- '''
155
- def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
156
- """Generates audio using the exact method from the Streamlit app and uploads it."""
157
  try:
158
- response = client.models.generate_content(
159
- model=TTS_MODEL,
160
- contents=f"""You are an articulate AI assistant — confident and precise like Jarvis.Rephrase the instruction naturally using simple expert language.
161
- Speak with a brisk, clear British accent.
162
- Avoid reading word for word — explain it like you know it.
163
- No quips or acknowledging the prompt just narrate this step:
164
- {text_to_speak}""",
165
- config=types.GenerateContentConfig(
166
- response_modalities=["AUDIO"],
167
- speech_config=types.SpeechConfig(
168
- voice_config=types.VoiceConfig(
169
- prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name='Sadaltager')
170
- )
171
- ),
172
- )
173
- )
174
- audio_part = response.candidates[0].content.parts[0]
175
- audio_data = audio_part.inline_data.data
176
- mime_type = audio_part.inline_data.mime_type
177
-
178
- final_audio_bytes = _convert_pcm_to_wav(audio_data) if 'pcm' in mime_type else audio_data
179
-
180
- audio_path = f"users/{uid}/projects/{project_id}/narrations/step_{step_num}.wav"
181
- return upload_to_storage(final_audio_bytes, audio_path, 'audio/wav')
182
- except Exception as e:
183
- print(f"Error during TTS generation for step {step_num}: {e}")
184
- return None
185
- '''
186
 
187
- # DeepGram faster and efficient
188
- def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
189
- """
190
- Generates audio using the Deepgram TTS API and uploads it to Firebase Storage.
191
- This is a drop-in replacement for the previous Google GenAI TTS function.
192
- """
193
  try:
194
- # --- Step 1: Get the Deepgram API Key from environment variables ---
195
- api_key = os.environ.get("DEEPGRAM_API_KEY")
196
- if not api_key:
197
- print("FATAL: DEEPGRAM_API_KEY environment variable not set.")
198
- return None
199
-
200
- # --- Step 2: Define the API endpoint and headers ---
201
- # The model 'aura-2-draco-en' is specified as a query parameter in the URL.
202
- DEEPGRAM_URL = "https://api.deepgram.com/v1/speak?model=aura-2-draco-en"
203
-
204
- headers = {
205
- "Authorization": f"Token {api_key}",
206
- "Content-Type": "text/plain" # As per Deepgram's requirement for this type of request
207
- }
208
-
209
- # --- Step 3: Make the API call to Deepgram ---
210
- # Deepgram expects the raw text as the request body, not in a JSON object.
211
- # We send the text directly in the 'data' parameter.
212
- response = requests.post(DEEPGRAM_URL, headers=headers, data=text_to_speak.encode('utf-8'))
213
-
214
- # Raise an exception for bad status codes (4xx or 5xx)
215
- response.raise_for_status()
216
-
217
- # The raw audio data is in the response content
218
- audio_data = response.content
219
-
220
- # --- Step 4: Upload the received audio to Firebase Storage ---
221
- # The output format from this Deepgram model is MP3.
222
- audio_path = f"users/{uid}/projects/{project_id}/narrations/step_{step_num}.mp3"
223
-
224
- # The MIME type for MP3 is 'audio/mpeg'.
225
- narration_url = upload_to_storage(audio_data, audio_path, 'audio/mpeg')
226
-
227
- return narration_url
228
-
229
- except requests.exceptions.RequestException as e:
230
- print(f"Error during Deepgram API call for step {step_num}: {e}")
231
- # Log the response body if available for more detailed error info
232
- if e.response is not None:
233
- print(f"Deepgram Error Response: {e.response.text}")
234
- return None
235
- except Exception as e:
236
- print(f"An unexpected error occurred during TTS generation for step {step_num}: {e}")
237
- return None
238
-
239
-
240
- def send_text_request(model_name, prompt, image):
241
- """Helper to send requests that expect only a text response."""
242
- try:
243
- chat = client.chats.create(model=model_name)
244
- response = chat.send_message([prompt, image])
245
- response_text = "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text'))
246
- return response_text.strip()
247
- except Exception as e:
248
- print(f"Error with model {model_name}: {e}")
249
- return None
250
-
251
-
252
- import logging
253
-
254
- # Configure logging at the top of your file if not already done
255
- logging.basicConfig(level=logging.INFO)
256
- logger = logging.getLogger(__name__)
257
-
258
- ## Stripe
259
- # Stripe plan config
260
- PLAN_CONFIG = {
261
- # The Fixer – standard
262
- "standard": {
263
- "price_id": STRIPE_PRICE_FIXER,
264
- "credits": 200,
265
- "label": "The Fixer",
266
- },
267
- "fixer": {
268
- "price_id": STRIPE_PRICE_FIXER,
269
- "credits": 200,
270
- "label": "The Fixer",
271
- },
272
-
273
- # The Pro – premium
274
- "premium": {
275
- "price_id": STRIPE_PRICE_PRO,
276
- "credits": 500,
277
- "label": "The Pro",
278
- },
279
- "pro": {
280
- "price_id": STRIPE_PRICE_PRO,
281
- "credits": 500,
282
- "label": "The Pro",
283
- },
284
- }
285
-
286
- def get_or_create_stripe_customer(uid: str) -> str:
287
- """
288
- Ensure the Firebase user has a Stripe customer.
289
- Stores the customer id on /users/{uid}/stripeCustomerId.
290
- """
291
- user_ref = db_ref.child(f'users/{uid}')
292
- user_data = user_ref.get() or {}
293
-
294
- existing_id = user_data.get("stripeCustomerId")
295
- if existing_id:
296
- return existing_id
297
-
298
- email = user_data.get("email")
299
- customer = stripe.Customer.create(
300
- email=email,
301
- metadata={"firebase_uid": uid}
302
- )
303
-
304
- user_ref.update({"stripeCustomerId": customer.id})
305
- return customer.id
306
-
307
- def reset_plan_credits(uid: str, plan_key: str):
308
- """
309
- HARD RESET the user's credits to the monthly allowance for the given plan.
310
- NO ROLLOVER:
311
- - standard / fixer -> 200
312
- - premium / pro -> 500
313
-
314
- Any previous balance is overwritten.
315
- """
316
- plan_cfg = PLAN_CONFIG.get(plan_key)
317
- if not plan_cfg:
318
- logger.error(f"[STRIPE] Unknown plan '{plan_key}' for user {uid}")
319
- return
320
-
321
- monthly_credits = float(plan_cfg["credits"])
322
-
323
- user_ref = db_ref.child(f'users/{uid}')
324
- user_data = user_ref.get() or {}
325
- previous = user_data.get('credits', 0)
326
-
327
- user_ref.update({
328
- "credits": monthly_credits,
329
- "lastCreditResetAt": datetime.utcnow().isoformat(),
330
- "creditResetPlan": plan_key,
331
- })
332
-
333
- logger.info(
334
- f"[STRIPE] RESET credits for user {uid} to {monthly_credits} "
335
- f"for plan '{plan_key}' (no rollover). Previous balance: {previous}"
336
- )
337
-
338
- def update_user_subscription_from_stripe(subscription: dict):
339
- """
340
- Syncs Stripe subscription info into the Firebase user document.
341
-
342
- Stores:
343
- - currentPlan (key: standard/premium)
344
- - currentPlanLabel ("The Fixer"/"The Pro")
345
- - currentPlanMonthlyCredits (200/500)
346
- - planStatus (active/past_due/canceled/unpaid/etc.)
347
- - planCurrentPeriodEnd (ISO expiry for current period)
348
- - stripeSubscriptionId
349
- - planUpdatedAt
350
- """
351
- metadata = subscription.get("metadata") or {}
352
- uid = metadata.get("firebase_uid")
353
- plan_key = metadata.get("plan")
354
-
355
- if not uid or not plan_key:
356
- logger.error("[STRIPE] Subscription missing firebase_uid or plan in metadata.")
357
- return
358
-
359
- plan_cfg = PLAN_CONFIG.get(plan_key, {})
360
- plan_label = plan_cfg.get("label", plan_key.title())
361
- monthly_credits = plan_cfg.get("credits", 0)
362
-
363
- status = subscription.get("status") # active, past_due, canceled, unpaid, trialing, etc.
364
- current_period_end = subscription.get("current_period_end") # Unix timestamp
365
-
366
- if current_period_end:
367
- expiry_iso = datetime.utcfromtimestamp(current_period_end).isoformat() + "Z"
368
- else:
369
- expiry_iso = None
370
-
371
- user_ref = db_ref.child(f'users/{uid}')
372
- update_data = {
373
- "currentPlan": plan_key, # "standard" / "premium"
374
- "currentPlanLabel": plan_label, # "The Fixer" / "The Pro"
375
- "currentPlanMonthlyCredits": monthly_credits, # 200 / 500
376
- "planStatus": status, # active / past_due / canceled / unpaid ...
377
- "planCurrentPeriodEnd": expiry_iso, # ISO timestamp of current period end
378
- "stripeSubscriptionId": subscription.get("id"),
379
- "planUpdatedAt": datetime.utcnow().isoformat(),
380
- }
381
-
382
- logger.info(
383
- f"[STRIPE] Updating subscription for user {uid}: "
384
- f"plan={plan_key}, status={status}, expires={expiry_iso}"
385
- )
386
- user_ref.update(update_data)
387
- # =============================================================================
388
- # OPEN IMAGE PROXY ENDPOINT (NO AUTHENTICATION)
389
- # =============================================================================
390
- @app.route('/api/image-proxy', methods=['GET'])
391
- def image_proxy():
392
- image_url = request.args.get('url')
393
- logger.info(f"[IMAGE PROXY] Received URL: {image_url}")
394
-
395
- if not image_url:
396
- logger.error("[IMAGE PROXY] ERROR: URL parameter is missing")
397
- return jsonify({'error': 'URL parameter is missing.'}), 400
398
-
399
- try:
400
- # Parse Firebase Storage URL
401
- # Expected format: https://storage.googleapis.com/bucket-name/path/to/file.ext
402
- if 'storage.googleapis.com' not in image_url:
403
- logger.error(f"[IMAGE PROXY] ERROR: Invalid Firebase Storage URL: {image_url}")
404
- return jsonify({'error': 'Invalid Firebase Storage URL.'}), 400
405
-
406
- logger.info(f"[IMAGE PROXY] Parsing URL: {image_url}")
407
-
408
- # Extract bucket name and blob path from the URL
409
- url_parts = image_url.split('storage.googleapis.com/')[1]
410
- logger.info(f"[IMAGE PROXY] URL parts after split: {url_parts}")
411
-
412
- # Remove query parameters if present
413
- url_parts = url_parts.split('?')[0]
414
- logger.info(f"[IMAGE PROXY] URL parts after removing query params: {url_parts}")
415
-
416
- # Split into bucket name and blob path
417
- path_components = url_parts.split('/', 1)
418
- logger.info(f"[IMAGE PROXY] Path components: {path_components}")
419
-
420
- if len(path_components) < 2:
421
- logger.error(f"[IMAGE PROXY] ERROR: Invalid URL format - path_components: {path_components}")
422
- return jsonify({'error': 'Invalid URL format.'}), 400
423
-
424
- url_bucket_name = path_components[0]
425
- blob_path = path_components[1]
426
-
427
- logger.info(f"[IMAGE PROXY] Extracted bucket name: {url_bucket_name}")
428
- logger.info(f"[IMAGE PROXY] Extracted blob path: {blob_path}")
429
-
430
- # Verify bucket name matches (optional security check)
431
- expected_bucket_name = bucket.name
432
- logger.info(f"[IMAGE PROXY] Expected bucket name: {expected_bucket_name}")
433
-
434
- if url_bucket_name != expected_bucket_name:
435
- logger.error(f"[IMAGE PROXY] ERROR: Bucket name mismatch - URL: {url_bucket_name}, Expected: {expected_bucket_name}")
436
- return jsonify({'error': 'Bucket name mismatch.'}), 403
437
-
438
- logger.info(f"[IMAGE PROXY] Creating blob object for path: {blob_path}")
439
-
440
- # Get the blob
441
- blob = bucket.blob(blob_path)
442
-
443
- logger.info(f"[IMAGE PROXY] Checking if blob exists...")
444
- if not blob.exists():
445
- logger.error(f"[IMAGE PROXY] ERROR: Image not found at path: {blob_path}")
446
- return jsonify({'error': 'Image not found.'}), 404
447
-
448
- logger.info(f"[IMAGE PROXY] Downloading blob...")
449
- # Download and return the image
450
- image_bytes = blob.download_as_bytes()
451
- content_type = blob.content_type or 'application/octet-stream'
452
-
453
- logger.info(f"[IMAGE PROXY] Successfully downloaded {len(image_bytes)} bytes, content-type: {content_type}")
454
-
455
- # Add cache headers for better performance
456
- response = Response(image_bytes, content_type=content_type)
457
- response.headers['Cache-Control'] = 'public, max-age=3600' # Cache for 1 hour
458
- return response
459
-
460
- except IndexError as e:
461
- logger.error(f"[IMAGE PROXY] URL parsing IndexError: {e}")
462
- logger.error(f"[IMAGE PROXY] URL was: {image_url}")
463
- return jsonify({'error': 'Invalid URL format.'}), 400
464
- except Exception as e:
465
- # This will catch parsing errors or other unexpected issues.
466
- logger.error(f"[IMAGE PROXY] Unexpected error: {e}")
467
- logger.error(f"[IMAGE PROXY] Error type: {type(e).__name__}")
468
- logger.error(f"[IMAGE PROXY] URL was: {image_url}")
469
- import traceback
470
- logger.error(f"[IMAGE PROXY] Full traceback: {traceback.format_exc()}")
471
- return jsonify({'error': 'Internal server error processing the image request.'}), 500
472
 
473
  # -----------------------------------------------------------------------------
474
- # 3. AUTHENTICATION & USER MANAGEMENT (Corrected Version)
475
  # -----------------------------------------------------------------------------
476
 
477
- @app.route('/api/auth/signup', methods=['POST'])
478
- def signup():
479
- """
480
- Handles new user sign-up with email/password.
481
- ✅ FIX: Now accepts an optional 'displayName' and saves it.
482
- """
483
  try:
484
- data = request.get_json()
485
- email, password = data.get('email'), data.get('password')
486
- # Get the optional displayName
487
- display_name = data.get('displayName') # Will be None if not provided
488
-
489
- if not email or not password:
490
- return jsonify({'error': 'Email and password are required'}), 400
491
-
492
- # Create the user in Firebase Authentication, including the displayName if available
493
- user = auth.create_user(
494
- email=email,
495
- password=password,
496
- display_name=display_name
497
- )
498
-
499
- # Create the corresponding user profile in the Realtime Database
500
- user_ref = db_ref.child(f'users/{user.uid}')
501
- user_data = {
502
- 'email': email,
503
- 'displayName': display_name, # Save the name to the database
504
- 'credits': 15,
505
- 'is_admin': False,
506
- 'createdAt': datetime.utcnow().isoformat()
507
- }
508
- user_ref.set(user_data)
509
-
510
- logger.info(f"New user signed up: {user.uid}, Name: {display_name}")
511
- return jsonify({'success': True, 'uid': user.uid, **user_data}), 201
512
-
513
- except Exception as e:
514
- logger.error(f"Signup failed: {e}")
515
- # Provide a more specific error for existing users
516
- if 'EMAIL_EXISTS' in str(e):
517
- return jsonify({'error': 'An account with this email already exists.'}), 409
518
- return jsonify({'error': str(e)}), 400
519
-
520
- @app.route('/api/auth/social-signin', methods=['POST'])
521
- def social_signin():
522
- """
523
- Ensures a user record exists and is up-to-date in the Realtime Database.
524
- ✅ IMPROVEMENT: Now backfills the displayName for existing social users
525
- if it's missing from the database.
526
- """
527
- uid = verify_token(request.headers.get('Authorization'))
528
- if not uid:
529
- return jsonify({'error': 'Invalid or expired token'}), 401
530
-
531
- user_ref = db_ref.child(f'users/{uid}')
532
- user_data = user_ref.get()
533
-
534
- if user_data:
535
- # User already exists. Check if their displayName is missing.
536
- if 'displayName' not in user_data or user_data['displayName'] is None:
537
- try:
538
- # The name is missing in our DB, let's sync it from Auth.
539
- firebase_user = auth.get_user(uid)
540
- if firebase_user.display_name:
541
- logger.info(f"Backfilling missing displayName for existing user {uid}.")
542
- user_ref.update({'displayName': firebase_user.display_name})
543
- # Get the updated data to return to the client
544
- user_data = user_ref.get()
545
- except Exception as e:
546
- logger.error(f"Could not backfill displayName for user {uid}: {e}")
547
-
548
- return jsonify({'uid': uid, **user_data}), 200
549
- else:
550
- # This is a new user (first social login), create their full profile.
551
- logger.info(f"New social user detected: {uid}. Creating database profile.")
552
- try:
553
- firebase_user = auth.get_user(uid)
554
- new_user_data = {
555
- 'email': firebase_user.email,
556
- 'displayName': firebase_user.display_name,
557
- 'credits': 15,
558
- 'is_admin': False,
559
- 'createdAt': datetime.utcnow().isoformat()
560
- }
561
- user_ref.set(new_user_data)
562
-
563
- logger.info(f"Successfully created profile for new social user: {uid}")
564
- return jsonify({'success': True, 'uid': uid, **new_user_data}), 201
565
- except Exception as e:
566
- logger.error(f"Error creating profile for new social user {uid}: {e}")
567
- return jsonify({'error': f'Failed to create user profile: {str(e)}'}), 500
568
-
569
-
570
- @app.route('/api/user/profile', methods=['GET'])
571
- def get_user_profile():
572
- """
573
- Retrieves the user's profile from the Realtime Database.
574
- ✅ FIX: This now correctly includes the 'displayName' in the response.
575
- """
576
- uid = verify_token(request.headers.get('Authorization'))
577
- if not uid:
578
- return jsonify({'error': 'Invalid or expired token'}), 401
579
-
580
- user_data = db_ref.child(f'users/{uid}').get()
581
- if not user_data:
582
- return jsonify({'error': 'User not found'}), 404
583
-
584
- return jsonify({'uid': uid, **user_data})
585
-
586
- @app.route('/api/user/profile', methods=['PUT'])
587
- def update_user_profile():
588
- """
589
- ✅ NEW: Allows a logged-in user to update their profile, specifically their displayName.
590
- """
591
- uid = verify_token(request.headers.get('Authorization'))
592
- if not uid:
593
- return jsonify({'error': 'Invalid or expired token'}), 401
594
-
595
- data = request.get_json()
596
- new_display_name = data.get('displayName')
597
-
598
- if not new_display_name or not isinstance(new_display_name, str) or len(new_display_name.strip()) == 0:
599
- return jsonify({'error': 'A valid displayName is required.'}), 400
600
-
601
- try:
602
- # Step 1: Update the user record in Firebase Authentication
603
- auth.update_user(uid, display_name=new_display_name)
604
-
605
- # Step 2: Update the user profile in the Realtime Database
606
- user_ref = db_ref.child(f'users/{uid}')
607
- user_ref.update({'displayName': new_display_name})
608
-
609
- logger.info(f"User {uid} updated their displayName to '{new_display_name}'.")
610
- return jsonify({'success': True, 'message': 'Profile updated successfully.'}), 200
611
-
612
- except Exception as e:
613
- logger.error(f"Error updating profile for user {uid}: {e}")
614
- return jsonify({'error': f'Failed to update profile: {str(e)}'}), 500
615
-
616
- # -----------------------------------------------------------------------------
617
- # 4. FEEDBACK AND CREDIT REQUESTS (USER-FACING)
618
- # -----------------------------------------------------------------------------
619
-
620
- @app.route('/api/feedback', methods=['POST'])
621
- def submit_feedback():
622
- uid = verify_token(request.headers.get('Authorization'))
623
- if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
624
-
625
- try:
626
- data = request.get_json()
627
- if not data or not data.get('message'): return jsonify({'error': 'Message is required'}), 400
628
 
629
- user_email = (db_ref.child(f'users/{uid}').get() or {}).get('email', 'unknown')
 
630
 
631
- feedback_ref = db_ref.child('feedback').push()
632
- feedback_record = {
633
- "feedbackId": feedback_ref.key,
634
- "userId": uid,
635
- "userEmail": user_email,
636
- "type": data.get('type', 'general'),
637
- "message": data.get('message'),
638
- "createdAt": datetime.utcnow().isoformat(),
639
- "status": "open"
640
- }
641
- feedback_ref.set(feedback_record)
642
- return jsonify({"success": True, "feedbackId": feedback_ref.key}), 201
643
- except Exception as e:
644
- return jsonify({'error': str(e)}), 500
645
-
646
- @app.route('/api/user/request-credits', methods=['POST'])
647
- def request_credits():
648
- uid = verify_token(request.headers.get('Authorization'))
649
- if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
650
-
651
- try:
652
- data = request.get_json()
653
- if not data or 'requested_credits' not in data: return jsonify({'error': 'requested_credits is required'}), 400
654
 
655
- request_ref = db_ref.child('credit_requests').push()
656
- request_ref.set({
657
- 'requestId': request_ref.key,
658
- 'userId': uid,
659
- 'requested_credits': data['requested_credits'],
660
- 'status': 'pending',
661
- 'requestedAt': datetime.utcnow().isoformat()
662
- })
663
- return jsonify({'success': True, 'requestId': request_ref.key})
664
  except Exception as e:
665
- return jsonify({'error': str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
666
 
667
  # -----------------------------------------------------------------------------
668
- # 5. ADMIN ENDPOINTS
669
  # -----------------------------------------------------------------------------
670
 
671
- @app.route('/api/admin/profile', methods=['GET'])
672
- def get_admin_profile():
673
- try:
674
- admin_uid = verify_admin(request.headers.get('Authorization'))
675
-
676
- # Fetch all necessary data from Firebase in one go
677
- all_users = db_ref.child('users').get() or {}
678
- all_projects = db_ref.child('projects').get() or {}
679
- all_feedback = db_ref.child('feedback').get() or {}
680
- all_credit_requests = db_ref.child('credit_requests').get() or {}
681
-
682
- # --- User Statistics Calculation ---
683
- total_users = len(all_users)
684
- admin_count = 0
685
- total_credits_in_system = 0
686
- new_users_last_7_days = 0
687
- seven_days_ago = datetime.utcnow() - timedelta(days=7)
688
-
689
- for user_data in all_users.values():
690
- if user_data.get('is_admin', False):
691
- admin_count += 1
692
- total_credits_in_system += user_data.get('credits', 0)
693
-
694
- # Check for new users
695
- try:
696
- created_at_str = user_data.get('createdAt')
697
- if created_at_str:
698
- # Accommodate different possible ISO formats
699
- user_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
700
- if user_created_at.replace(tzinfo=None) > seven_days_ago:
701
- new_users_last_7_days += 1
702
- except (ValueError, TypeError):
703
- # Ignore if date format is invalid or missing
704
- pass
705
-
706
- # --- Project Statistics Calculation ---
707
- total_projects = len(all_projects)
708
- projects_by_status = {
709
- "awaiting_approval": 0,
710
- "awaiting_selection": 0,
711
- "ready": 0,
712
- "unknown": 0
713
- }
714
- projects_by_category = {}
715
-
716
- for project_data in all_projects.values():
717
- # Tally by status
718
- status = project_data.get('status', 'unknown')
719
- projects_by_status[status] = projects_by_status.get(status, 0) + 1
720
-
721
- # Tally by category
722
- category = project_data.get('category', 'N/A')
723
- projects_by_category[category] = projects_by_category.get(category, 0) + 1
724
-
725
- # --- System Health Calculation ---
726
- open_feedback_count = sum(1 for fb in all_feedback.values() if fb.get('status') == 'open')
727
- pending_requests_count = sum(1 for req in all_credit_requests.values() if req.get('status') == 'pending')
728
-
729
- # Assemble the final response object
730
- admin_personal_data = all_users.get(admin_uid, {})
731
-
732
- response_data = {
733
- 'uid': admin_uid,
734
- 'email': admin_personal_data.get('email'),
735
- 'credits': admin_personal_data.get('credits'),
736
- 'is_admin': True,
737
- 'dashboardStats': {
738
- 'users': {
739
- 'total': total_users,
740
- 'admins': admin_count,
741
- 'regular': total_users - admin_count,
742
- 'newLast7Days': new_users_last_7_days,
743
- 'totalCreditsInSystem': total_credits_in_system
744
- },
745
- 'projects': {
746
- 'total': total_projects,
747
- 'byStatus': projects_by_status,
748
- 'byCategory': projects_by_category
749
- },
750
- 'system': {
751
- 'openFeedback': open_feedback_count,
752
- 'pendingCreditRequests': pending_requests_count
753
- }
754
- }
755
- }
756
-
757
- return jsonify(response_data), 200
758
-
759
- except PermissionError as e:
760
- return jsonify({'error': str(e)}), 403 # Use 403 Forbidden for permission issues
761
- except Exception as e:
762
- print(traceback.format_exc())
763
- return jsonify({'error': f"An internal error occurred: {e}"}), 500
764
-
765
- @app.route('/api/admin/credit_requests', methods=['GET'])
766
- def list_credit_requests():
767
- try:
768
- verify_admin(request.headers.get('Authorization'))
769
- requests_data = db_ref.child('credit_requests').get() or {}
770
- return jsonify(list(requests_data.values()))
771
- except Exception as e:
772
- return jsonify({'error': str(e)}), 500
773
-
774
- @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT'])
775
- def process_credit_request(request_id):
776
- try:
777
- admin_uid = verify_admin(request.headers.get('Authorization'))
778
- req_ref = db_ref.child(f'credit_requests/{request_id}')
779
- req_data = req_ref.get()
780
- if not req_data: return jsonify({'error': 'Credit request not found'}), 404
781
-
782
- decision = request.json.get('decision')
783
- if decision not in ['approved', 'declined']: return jsonify({'error': 'Decision must be "approved" or "declined"'}), 400
784
-
785
- if decision == 'approved':
786
- user_ref = db_ref.child(f'users/{req_data["userId"]}')
787
- user_data = user_ref.get()
788
- if user_data:
789
- new_total = user_data.get('credits', 0) + int(req_data.get('requested_credits', 0))
790
- user_ref.update({'credits': new_total})
791
-
792
- req_ref.update({'status': decision, 'processedBy': admin_uid, 'processedAt': datetime.utcnow().isoformat()})
793
- return jsonify({'success': True, 'message': f'Request {decision}.'})
794
- except Exception as e:
795
- return jsonify({'error': str(e)}), 500
796
-
797
- @app.route('/api/admin/feedback', methods=['GET'])
798
- def admin_view_feedback():
799
- try:
800
- verify_admin(request.headers.get('Authorization'))
801
- feedback_data = db_ref.child('feedback').get() or {}
802
- return jsonify(list(feedback_data.values()))
803
- except Exception as e:
804
- return jsonify({'error': str(e)}), 500
805
-
806
- @app.route('/api/admin/users', methods=['GET'])
807
- def admin_list_users():
808
- try:
809
- verify_admin(request.headers.get('Authorization'))
810
- all_users = db_ref.child('users').get() or {}
811
- user_list = [{'uid': uid, **data} for uid, data in all_users.items()]
812
- return jsonify(user_list)
813
- except Exception as e:
814
- return jsonify({'error': str(e)}), 500
815
-
816
- @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT'])
817
- def admin_update_credits(uid):
818
- try:
819
- verify_admin(request.headers.get('Authorization'))
820
- add_credits = request.json.get('add_credits')
821
- if add_credits is None: return jsonify({'error': 'add_credits is required'}), 400
822
-
823
- user_ref = db_ref.child(f'users/{uid}')
824
- user_data = user_ref.get()
825
- if not user_data: return jsonify({'error': 'User not found'}), 404
826
-
827
- new_total = user_data.get('credits', 0) + float(add_credits)
828
- user_ref.update({'credits': new_total})
829
- return jsonify({'success': True, 'new_total_credits': new_total})
830
- except Exception as e:
831
- return jsonify({'error': str(e)}), 500
832
-
833
- # -----------------------------------------------------------------------------
834
- # 6. DIY PROJECT ENDPOINTS (Core Logic)
835
- # -----------------------------------------------------------------------------
836
- # (The project endpoints from the previous answer go here, unchanged)
837
- @app.route('/api/projects', methods=['POST'])
838
- def create_project():
839
  uid = verify_token(request.headers.get('Authorization'))
840
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
841
 
842
  user_ref = db_ref.child(f'users/{uid}')
843
  user_data = user_ref.get()
844
  if not user_data or user_data.get('credits', 0) < 1:
845
- return jsonify({'error': 'Insufficient credits'}), 402
846
 
847
  if 'image' not in request.files:
848
- return jsonify({'error': 'Image file is required'}), 400
849
 
850
  image_file = request.files['image']
851
- context_text = request.form.get('contextText', '')
852
  image_bytes = image_file.read()
853
- pil_image = Image.open(io.BytesIO(image_bytes))
854
 
855
  try:
856
- category_prompt = (
857
- "You are an expert DIY assistant. Analyze the user's image and context. "
858
- f"Context: '{context_text}'. "
859
- "Categorize the project into ONE of the following: "
860
- "Home Appliance Repair, Automotive Maintenance, Gardening & Urban Farming, "
861
- "Upcycling & Sustainable Crafts, or DIY Project Creation. "
862
- "Reply with ONLY the category name."
863
- )
864
- category = send_text_request(CATEGORY_MODEL, category_prompt, pil_image)
865
- if not category: return jsonify({'error': 'Failed to get project category from AI.'}), 500
866
-
867
- plan_prompt = f"""
868
- You are an expert DIY assistant in the category: {category}.
869
- User Context: "{context_text if context_text else 'No context provided.'}"
870
- Based on the image and context, perform the following:
871
- 1. **Title:** Create a short, clear title for this project.
872
- 2. **Description:** Write a brief, one-paragraph description of the goal.
873
- 3. **Initial Plan:**
874
- - If 'Upcycling & Sustainable Crafts' AND no specific project is mentioned, propose three distinct project options as a numbered list under "UPCYCLING OPTIONS:".
875
- - For all other cases, briefly outline the main stages of the proposed solution.
876
- Structure your response EXACTLY like this:
877
- TITLE: [Your title]
878
- DESCRIPTION: [Your description]
879
- INITIAL PLAN:
880
- [Your plan or 3 options]
881
  """
882
- plan_response = send_text_request(GENERATION_MODEL, plan_prompt, pil_image)
883
- if not plan_response: return jsonify({'error': 'Failed to generate project plan from AI.'}), 500
884
 
885
- title = re.search(r"TITLE:\s*(.*)", plan_response).group(1).strip()
886
- description = re.search(r"DESCRIPTION:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
887
- initial_plan_text = re.search(r"INITIAL PLAN:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
888
-
889
- upcycling_options = re.findall(r"^\s*\d+\.\s*(.*)", initial_plan_text, re.MULTILINE) if "UPCYCLING OPTIONS:" in initial_plan_text else []
890
- initial_plan = initial_plan_text if not upcycling_options else ""
891
- status = "awaiting_selection" if upcycling_options else "awaiting_approval"
892
 
893
- project_id = str(uuid.uuid4())
894
- image_path = f"users/{uid}/projects/{project_id}/initial_image.png"
895
- image_url = upload_to_storage(image_bytes, image_path, content_type=image_file.content_type)
896
-
897
- project_data = {
898
- "uid": uid, "projectId": project_id, "status": status, "createdAt": datetime.utcnow().isoformat(),
899
- "userImageURL": image_url, "contextText": context_text, "projectTitle": title,
900
- "projectDescription": description, "category": category, "initialPlan": initial_plan,
901
- "upcyclingOptions": upcycling_options, "toolsList": [], "steps": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  }
903
- db_ref.child(f'projects/{project_id}').set(project_data)
904
 
 
905
  user_ref.update({'credits': user_data.get('credits', 0) - 1})
906
- return jsonify(project_data), 201
 
907
 
908
  except Exception as e:
909
  print(traceback.format_exc())
910
- return jsonify({'error': f"An error occurred: {e}"}), 500
911
-
912
 
913
- @app.route('/api/projects/<string:project_id>/approve', methods=['PUT'])
914
- def approve_project_plan(project_id):
915
- start_time = time.time()
916
- logger.info(f"[PROJECT APPROVAL] Starting approval process for project: {project_id}")
917
-
918
- # Authorization timing
919
- auth_start = time.time()
920
  uid = verify_token(request.headers.get('Authorization'))
921
- if not uid:
922
- logger.error(f"[PROJECT APPROVAL] ERROR: Unauthorized access attempt for project: {project_id}")
923
- return jsonify({'error': 'Unauthorized'}), 401
924
- auth_time = time.time() - auth_start
925
- logger.info(f"[PROJECT APPROVAL] Authorization completed in {auth_time:.3f}s for user: {uid}")
926
-
927
- # User data fetch timing
928
- user_fetch_start = time.time()
929
- user_ref = db_ref.child(f'users/{uid}')
930
- user_data = user_ref.get()
931
- if not user_data or user_data.get('credits', 0) < 5:
932
- logger.error(f"[PROJECT APPROVAL] ERROR: Insufficient credits for user: {uid}, credits: {user_data.get('credits', 0) if user_data else 0}")
933
- return jsonify({'error': 'Insufficient credits'}), 402
934
- user_fetch_time = time.time() - user_fetch_start
935
- logger.info(f"[PROJECT APPROVAL] User data fetch completed in {user_fetch_time:.3f}s, credits: {user_data.get('credits', 0)}")
936
 
937
- # Project data fetch timing
938
- project_fetch_start = time.time()
939
- project_ref = db_ref.child(f'projects/{project_id}')
940
- project_data = project_ref.get()
941
- if not project_data or project_data.get('uid') != uid:
942
- logger.error(f"[PROJECT APPROVAL] ERROR: Project not found or access denied - project_id: {project_id}, uid: {uid}")
943
- return jsonify({'error': 'Project not found or access denied'}), 404
944
- project_fetch_time = time.time() - project_fetch_start
945
- logger.info(f"[PROJECT APPROVAL] Project data fetch completed in {project_fetch_time:.3f}s for project: {project_data.get('projectTitle', 'Unknown')}")
946
 
947
- # Image download and processing timing
948
- selected_option = request.json.get('selectedOption')
949
- logger.info(f"[PROJECT APPROVAL] Selected option: {selected_option}")
950
-
951
- image_download_start = time.time()
952
- try:
953
- response = requests.get(project_data['userImageURL'], timeout=30)
954
- response.raise_for_status()
955
- except requests.RequestException as e:
956
- logger.error(f"[PROJECT APPROVAL] ERROR: Image download failed: {e}")
957
- return jsonify({'error': 'Failed to download project image'}), 500
958
-
959
- image_download_time = time.time() - image_download_start
960
- logger.info(f"[PROJECT APPROVAL] Image download completed in {image_download_time:.3f}s, size: {len(response.content)} bytes")
961
 
962
- image_processing_start = time.time()
963
  try:
964
- pil_image = Image.open(io.BytesIO(response.content)).convert('RGB')
 
 
 
965
  except Exception as e:
966
- logger.error(f"[PROJECT APPROVAL] ERROR: Image processing failed: {e}")
967
- return jsonify({'error': 'Failed to process project image'}), 500
968
-
969
- image_processing_time = time.time() - image_processing_start
970
- logger.info(f"[PROJECT APPROVAL] Image processing completed in {image_processing_time:.3f}s")
971
 
972
- # Context preparation timing
973
- context_start = time.time()
974
- context = (
975
- f"The user chose the upcycling project: '{selected_option}'."
976
- if selected_option
977
- else f"The user has approved the plan for '{project_data['projectTitle']}'."
978
- )
979
 
980
- detailed_prompt = f"""
981
- You are a DIY expert. The user wants to proceed with the project titled "{project_data['projectTitle']}".
982
- {context}
983
- Provide a detailed guide. For each step, you MUST provide a simple, clear illustrative image.
984
- Format your response EXACTLY like this:
985
- TOOLS AND MATERIALS:
986
- - Tool A
987
- - Material B
988
- STEPS(Maximum 5 steps):
989
- 1. First step instructions.
990
- 2. Second step instructions...
991
- """
992
- context_time = time.time() - context_start
993
- logger.info(f"[PROJECT APPROVAL] Context preparation completed in {context_time:.3f}s")
994
 
995
  try:
996
- # AI generation timing
997
- ai_start = time.time()
998
- logger.info(f"[PROJECT APPROVAL] Starting AI generation with model: {GENERATION_MODEL}")
999
-
1000
- chat = client.chats.create(
1001
- model=GENERATION_MODEL,
1002
- config=types.GenerateContentConfig(response_modalities=["Text", "Image"])
1003
- )
1004
- full_resp = chat.send_message([detailed_prompt, pil_image])
1005
- ai_time = time.time() - ai_start
1006
- logger.info(f"[PROJECT APPROVAL] AI generation completed in {ai_time:.3f}s")
1007
-
1008
- # Response parsing timing
1009
- parsing_start = time.time()
1010
- gen_parts = full_resp.candidates[0].content.parts
1011
-
1012
- combined_text = ""
1013
- inline_images = []
1014
- for part in gen_parts:
1015
- if part.text is not None:
1016
- combined_text += part.text + "\n"
1017
- if part.inline_data is not None:
1018
- img = Image.open(io.BytesIO(part.inline_data.data)).convert('RGB')
1019
- inline_images.append(img)
1020
-
1021
- combined_text = combined_text.strip()
1022
- parsing_time = time.time() - parsing_start
1023
- logger.info(f"[PROJECT APPROVAL] Response parsing completed in {parsing_time:.3f}s, found {len(inline_images)} images")
1024
-
1025
- # Text extraction timing with robust error handling
1026
- extraction_start = time.time()
1027
-
1028
- # Add debug logging to see what the AI actually returned
1029
- logger.info(f"[PROJECT APPROVAL] AI Response structure check:")
1030
- logger.info(f"[PROJECT APPROVAL] Full response length: {len(combined_text)}")
1031
- logger.info(f"[PROJECT APPROVAL] Contains 'TOOLS AND MATERIALS': {'TOOLS AND MATERIALS' in combined_text.upper()}")
1032
- logger.info(f"[PROJECT APPROVAL] Contains 'STEPS': {'STEPS' in combined_text.upper()}")
1033
- logger.info(f"[PROJECT APPROVAL] Response preview: {combined_text[:300]}...")
1034
-
1035
- # More robust regex patterns with error handling - updated for your production format
1036
- tools_match = re.search(r"TOOLS AND MATERIALS:\s*(.*?)\s*(?=STEPS\s*\(|STEPS\s*:|$)", combined_text, re.DOTALL | re.IGNORECASE)
1037
- steps_match = re.search(r"STEPS\s*\([^)]*\):\s*(.*)|STEPS\s*:\s*(.*)", combined_text, re.DOTALL | re.IGNORECASE)
1038
-
1039
- if not tools_match:
1040
- logger.error(f"[PROJECT APPROVAL] ERROR: Could not find TOOLS AND MATERIALS section in AI response")
1041
- logger.error(f"[PROJECT APPROVAL] AI Response full text: {combined_text}")
1042
- return jsonify({'error': 'AI response format error: Could not parse tools section'}), 500
1043
-
1044
- if not steps_match:
1045
- logger.error(f"[PROJECT APPROVAL] ERROR: Could not find STEPS section in AI response")
1046
- logger.error(f"[PROJECT APPROVAL] AI Response full text: {combined_text}")
1047
- return jsonify({'error': 'AI response format error: Could not parse steps section'}), 500
1048
-
1049
- tools_section = tools_match.group(1).strip()
1050
- steps_section = (steps_match.group(1) or steps_match.group(2)).strip() if steps_match else ""
1051
-
1052
- # Additional validation
1053
- if not tools_section:
1054
- logger.error(f"[PROJECT APPROVAL] ERROR: Empty tools section found")
1055
- return jsonify({'error': 'AI response format error: Empty tools section'}), 500
1056
-
1057
- if not steps_section:
1058
- logger.error(f"[PROJECT APPROVAL] ERROR: Empty steps section found")
1059
- return jsonify({'error': 'AI response format error: Empty steps section'}), 500
1060
-
1061
- tools_list = [line.strip("- ").strip() for line in tools_section.split('\n') if line.strip() and not line.strip().startswith('-')]
1062
- # Also include lines that start with dashes
1063
- dash_tools = [line.strip("- ").strip() for line in tools_section.split('\n') if line.strip().startswith('-')]
1064
- tools_list.extend(dash_tools)
1065
-
1066
- # Remove duplicates while preserving order
1067
- seen = set()
1068
- tools_list = [x for x in tools_list if not (x in seen or seen.add(x))]
1069
-
1070
- parsed_steps = parse_numbered_steps(steps_section)
1071
-
1072
- # Validate parsed results
1073
- if not tools_list:
1074
- logger.error(f"[PROJECT APPROVAL] ERROR: No tools parsed from response")
1075
- logger.error(f"[PROJECT APPROVAL] Tools section was: {tools_section}")
1076
- return jsonify({'error': 'AI response format error: No tools found'}), 500
1077
-
1078
- if not parsed_steps:
1079
- logger.error(f"[PROJECT APPROVAL] ERROR: No steps parsed from response")
1080
- logger.error(f"[PROJECT APPROVAL] Steps section was: {steps_section}")
1081
- return jsonify({'error': 'AI response format error: No steps found'}), 500
1082
-
1083
- extraction_time = time.time() - extraction_start
1084
- logger.info(f"[PROJECT APPROVAL] Text extraction completed in {extraction_time:.3f}s, tools: {len(tools_list)}, steps: {len(parsed_steps)}")
1085
-
1086
- if len(parsed_steps) != len(inline_images):
1087
- logger.error(f"[PROJECT APPROVAL] ERROR: AI response mismatch - Steps: {len(parsed_steps)}, Images: {len(inline_images)}")
1088
- # Try to handle the mismatch gracefully
1089
- min_length = min(len(parsed_steps), len(inline_images))
1090
- if min_length > 0:
1091
- logger.info(f"[PROJECT APPROVAL] Attempting to proceed with {min_length} steps/images")
1092
- parsed_steps = parsed_steps[:min_length]
1093
- inline_images = inline_images[:min_length]
1094
- else:
1095
- return jsonify({'error': 'AI response mismatch: No valid steps and images found.'}), 500
1096
-
1097
- # Step processing timing
1098
- step_processing_start = time.time()
1099
- final_steps = []
1100
- total_upload_time = 0
1101
- total_tts_time = 0
1102
-
1103
- for i, step_info in enumerate(parsed_steps):
1104
- logger.info(f"[PROJECT APPROVAL] Processing step {i+1}/{len(parsed_steps)}")
1105
-
1106
- try:
1107
- # Image upload timing
1108
- image_upload_start = time.time()
1109
- img_byte_arr = io.BytesIO()
1110
- inline_images[i].save(img_byte_arr, format='JPEG', optimize=True, quality=70)
1111
- img_path = f"users/{uid}/projects/{project_id}/steps/step_{i+1}_image.jpg"
1112
- img_url = upload_to_storage(img_byte_arr.getvalue(), img_path, 'image/jpeg')
1113
- image_upload_time = time.time() - image_upload_start
1114
- total_upload_time += image_upload_time
1115
- logger.info(f"[PROJECT APPROVAL] Step {i+1} image upload completed in {image_upload_time:.3f}s")
1116
-
1117
- # TTS generation timing
1118
- tts_start = time.time()
1119
- narration_url = generate_tts_audio_and_upload(step_info['text'], uid, project_id, i + 1)
1120
- tts_time = time.time() - tts_start
1121
- total_tts_time += tts_time
1122
- logger.info(f"[PROJECT APPROVAL] Step {i+1} TTS generation completed in {tts_time:.3f}s")
1123
-
1124
- step_info.update({
1125
- "imageUrl": img_url,
1126
- "narrationUrl": narration_url,
1127
- "isDone": False,
1128
- "notes": ""
1129
- })
1130
- final_steps.append(step_info)
1131
-
1132
- except Exception as e:
1133
- logger.error(f"[PROJECT APPROVAL] ERROR processing step {i+1}: {e}")
1134
- # Continue with other steps rather than failing entirely
1135
- step_info.update({
1136
- "imageUrl": "",
1137
- "narrationUrl": "",
1138
- "isDone": False,
1139
- "notes": ""
1140
- })
1141
- final_steps.append(step_info)
1142
-
1143
- step_processing_time = time.time() - step_processing_start
1144
- logger.info(f"[PROJECT APPROVAL] All steps processing completed in {step_processing_time:.3f}s")
1145
- logger.info(f"[PROJECT APPROVAL] Total upload time: {total_upload_time:.3f}s, Total TTS time: {total_tts_time:.3f}s")
1146
-
1147
- # Database update timing
1148
- db_update_start = time.time()
1149
- update_data = {
1150
- "status": "ready",
1151
- "toolsList": tools_list,
1152
- "steps": final_steps,
1153
- "selectedOption": selected_option or ""
1154
- }
1155
- project_ref.update(update_data)
1156
- logger.info(f"[PROJECT APPROVAL] Updating data in db: {len(update_data)} fields")
1157
- db_update_time = time.time() - db_update_start
1158
- logger.info(f"[PROJECT APPROVAL] Database update completed in {db_update_time:.3f}s")
1159
-
1160
- # Final project fetch timing
1161
- final_fetch_start = time.time()
1162
- updated_project = project_ref.get()
1163
- updated_project["projectId"] = project_id
1164
- final_fetch_time = time.time() - final_fetch_start
1165
- logger.info(f"[PROJECT APPROVAL] Final project fetch completed in {final_fetch_time:.3f}s")
1166
-
1167
- # Credits deduction timing
1168
- credits_update_start = time.time()
1169
- user_ref.update({'credits': user_data.get('credits', 0) - 5})
1170
- credits_update_time = time.time() - credits_update_start
1171
- logger.info(f"[PROJECT APPROVAL] Credits update completed in {credits_update_time:.3f}s")
1172
-
1173
- # Total time calculation
1174
- total_time = time.time() - start_time
1175
- logger.info(f"[PROJECT APPROVAL] SUCCESS: Project approval completed in {total_time:.3f}s")
1176
- logger.info(f"[PROJECT APPROVAL] TIMING BREAKDOWN:")
1177
- logger.info(f"[PROJECT APPROVAL] - Authorization: {auth_time:.3f}s")
1178
- logger.info(f"[PROJECT APPROVAL] - User fetch: {user_fetch_time:.3f}s")
1179
- logger.info(f"[PROJECT APPROVAL] - Project fetch: {project_fetch_time:.3f}s")
1180
- logger.info(f"[PROJECT APPROVAL] - Image download: {image_download_time:.3f}s")
1181
- logger.info(f"[PROJECT APPROVAL] - Image processing: {image_processing_time:.3f}s")
1182
- logger.info(f"[PROJECT APPROVAL] - Context prep: {context_time:.3f}s")
1183
- logger.info(f"[PROJECT APPROVAL] - AI generation: {ai_time:.3f}s")
1184
- logger.info(f"[PROJECT APPROVAL] - Response parsing: {parsing_time:.3f}s")
1185
- logger.info(f"[PROJECT APPROVAL] - Text extraction: {extraction_time:.3f}s")
1186
- logger.info(f"[PROJECT APPROVAL] - Step processing: {step_processing_time:.3f}s")
1187
- logger.info(f"[PROJECT APPROVAL] - Total uploads: {total_upload_time:.3f}s")
1188
- logger.info(f"[PROJECT APPROVAL] - Total TTS: {total_tts_time:.3f}s")
1189
- logger.info(f"[PROJECT APPROVAL] - DB update: {db_update_time:.3f}s")
1190
- logger.info(f"[PROJECT APPROVAL] - Final fetch: {final_fetch_time:.3f}s")
1191
- logger.info(f"[PROJECT APPROVAL] - Credits update: {credits_update_time:.3f}s")
1192
 
1193
- return jsonify(updated_project)
1194
-
1195
  except Exception as e:
1196
- total_time = time.time() - start_time
1197
- logger.error(f"[PROJECT APPROVAL] ERROR: Exception occurred after {total_time:.3f}s: {e}")
1198
- logger.error(f"[PROJECT APPROVAL] Error type: {type(e).__name__}")
1199
- logger.error(f"[PROJECT APPROVAL] Project ID: {project_id}, User ID: {uid}")
1200
- import traceback
1201
- logger.error(f"[PROJECT APPROVAL] Full traceback: {traceback.format_exc()}")
1202
- return jsonify({'error': f'Internal server error: {str(e)}'}), 500
1203
-
1204
- @app.route('/api/projects', methods=['GET'])
1205
- def list_projects():
1206
- uid = verify_token(request.headers.get('Authorization'))
1207
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1208
- projects = (db_ref.child('projects').order_by_child('uid').equal_to(uid).get() or {}).values()
1209
- return jsonify(list(projects))
1210
 
1211
- @app.route('/api/projects/<string:project_id>', methods=['GET'])
1212
- def get_project(project_id):
1213
  uid = verify_token(request.headers.get('Authorization'))
1214
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
1215
- project_data = db_ref.child(f'projects/{project_id}').get()
1216
- if not project_data or project_data.get('uid') != uid:
1217
- return jsonify({'error': 'Project not found or access denied'}), 404
1218
- return jsonify(project_data)
1219
 
1220
- @app.route('/api/projects/<string:project_id>/step/<int:step_number>', methods=['PUT'])
1221
- def update_step(project_id, step_number):
1222
- uid = verify_token(request.headers.get('Authorization'))
1223
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1224
  data = request.get_json()
1225
- if data is None: return jsonify({'error': 'JSON body is required'}), 400
1226
-
1227
- project_data = db_ref.child(f'projects/{project_id}').get()
1228
- if not project_data or project_data.get('uid') != uid:
1229
- return jsonify({'error': 'Project not found or access denied'}), 404
1230
-
1231
- steps = project_data.get('steps', [])
1232
- step_index = next((i for i, s in enumerate(steps) if s.get('stepNumber') == step_number), -1)
1233
- if step_index == -1: return jsonify({'error': f'Step number {step_number} not found'}), 404
1234
-
1235
- step_path = f'projects/{project_id}/steps/{step_index}'
1236
- if 'isDone' in data: db_ref.child(f'{step_path}/isDone').set(bool(data['isDone']))
1237
- if 'notes' in data: db_ref.child(f'{step_path}/notes').set(str(data['notes']))
1238
 
1239
- return jsonify({"success": True, "updatedStep": db_ref.child(step_path).get()})
1240
-
1241
- @app.route('/api/projects/<string:project_id>', methods=['DELETE'])
1242
- def delete_project(project_id):
1243
- uid = verify_token(request.headers.get('Authorization'))
1244
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1245
 
1246
- project_ref = db_ref.child(f'projects/{project_id}')
1247
- project_data = project_ref.get()
1248
- if not project_data or project_data.get('uid') != uid:
1249
- return jsonify({'error': 'Project not found or access denied'}), 404
1250
-
1251
- project_ref.delete()
1252
- for blob in bucket.list_blobs(prefix=f"users/{uid}/projects/{project_id}/"):
1253
- blob.delete()
1254
- return jsonify({"success": True, "message": f"Project {project_id} deleted."})
1255
-
1256
-
1257
- #------------------------
1258
- # AI phone call ElevenLabs
1259
- #-------------------------
1260
- import math
1261
-
1262
- # This code should be added to your existing main.py file.
1263
- # It assumes 'db_ref', 'client', 'logger', and 'verify_token' are already defined.
1264
-
1265
- def summarize_user_history(uid):
1266
- """
1267
- Fetches all of a user's past transcripts, sends them to Gemini for analysis,
1268
- and returns a concise, actionable summary for the AI agent.
1269
- """
1270
  try:
1271
- logger.info(f"[BRIEFING] Fetching transcript history for user: {uid}")
1272
- transcripts_ref = db_ref.child(f'transcripts/{uid}')
1273
- all_transcripts = transcripts_ref.get()
1274
-
1275
- if not all_transcripts:
1276
- logger.info(f"[BRIEFING] No history found for user {uid}. Returning 'new user' summary.")
1277
- return "This is a new user."
1278
 
1279
- history_list = []
1280
- for transcript_data in all_transcripts.values():
1281
- history_list.append({
1282
- "date": transcript_data.get("createdAt"),
1283
- "project_title": transcript_data.get("projectId"),
1284
- "transcript": transcript_data.get("transcript")
1285
  })
1286
-
1287
- history_json = json.dumps(history_list, indent=2)
1288
-
1289
- analyst_prompt = """
1290
- You are a world-class executive assistant and data analyst. Your job is to analyze a user's conversation history with a DIY expert AI named Alfred. Your goal is to produce a 'Pre-Call Briefing' for Alfred that is concise, insightful, and focuses on personal details.
1291
-
1292
- The history will be a JSON string containing a list of past conversations. Each conversation has a date, project title, and a full transcript.
1293
-
1294
- Your task is to identify patterns and extract the following key details. If a detail is not mentioned, omit that line from the output.
1295
- - Personal Context: Identify who the projects are for (e.g., 'wife', 'son') and any mentioned personal details (e.g., 'has a dog', 'works as a teacher').
1296
- - Preferences: Note any mentioned tastes or preferences (e.g., 'likes rustic style', 'favorite color is green').
1297
- - Skill Assessment: Assess their DIY skill level (e.g., 'Beginner, hesitant with power tools', 'Intermediate, comfortable with plumbing').
1298
- - Tool Inventory: List any specific tools they have mentioned owning or needing (e.g., 'Owns a power drill', 'Needed to buy a special wrench').
1299
- - Recurring Themes: Identify any patterns in their questions or struggles (e.g., 'Often asks for clarification on measurements', 'Struggles with painting techniques').
1300
-
1301
- Your output MUST be a series of bullet points. Do not add any conversational text or greetings. Start the entire response with 'Here is your briefing on this user:'.
1302
-
1303
- Example Output:
1304
- Here is your briefing on this user:
1305
- * Is building this project for: their daughter
1306
- * Personal context: has a golden retriever
1307
- * Assessed skill level: Beginner, but learns quickly
1308
- * Known tools: owns a basic screwdriver set
1309
- * Recurring themes: frequently asks for pet-safe material recommendations
1310
- * comorehensive summary of the transcript(s): More details about the user in a short paragraph
1311
- """
1312
-
1313
- logger.info(f"[BRIEFING] Sending {len(history_list)} transcripts to Gemini for summarization.")
1314
-
1315
- # ✅ THE FIX: Using the exact model name and API call structure you provided.
1316
- response = client.models.generate_content(
1317
- model='gemini-2.0-flash-lite',
1318
- contents=[analyst_prompt, history_json]
1319
- )
1320
-
1321
- summary = response.text.strip()
1322
- logger.info(f"[BRIEFING] Received summary from Gemini: {summary}")
1323
- return summary
1324
-
1325
  except Exception as e:
1326
- logger.error(f"[BRIEFING] Failed to generate user summary for {uid}: {e}")
1327
- return "Could not retrieve user history."
1328
 
 
 
 
1329
 
1330
- @app.route('/api/user/call-briefing', methods=['GET'])
1331
- def get_call_briefing():
1332
- """
1333
- The single endpoint for the frontend to call before starting a conversation.
1334
- It orchestrates getting the user's history and summarizing it.
1335
- """
1336
- logger.info("[BRIEFING] Received request for a new call briefing.")
1337
-
1338
  uid = verify_token(request.headers.get('Authorization'))
1339
- if not uid:
1340
- return jsonify({'error': 'Unauthorized'}), 401
1341
-
1342
- try:
1343
- memory_summary = summarize_user_history(uid)
1344
-
1345
- return jsonify({"memory_summary": memory_summary}), 200
1346
-
1347
- except Exception as e:
1348
- logger.error(f"An unexpected error occurred in get_call_briefing for user {uid}: {e}")
1349
- return jsonify({'error': 'Failed to generate call briefing.'}), 500
1350
-
1351
- @app.route('/api/debug/test-agent', methods=['GET'])
1352
- def test_agent():
1353
- """
1354
- Fixed debug endpoint that tests the CORRECT conversation endpoint.
1355
- """
1356
- if not ELEVENLABS_API_KEY:
1357
- return jsonify({'error': 'API key not set on server'}), 500
1358
-
1359
- headers = {"xi-api-key": ELEVENLABS_API_KEY}
1360
- results = {'agent_id': AGENT_ID, 'tests': {}}
1361
-
1362
- try:
1363
- # Test 1: Check if the agent can be found by its ID.
1364
- agent_url = f"https://api.elevenlabs.io/v1/convai/agents/{AGENT_ID}"
1365
- agent_resp = requests.get(agent_url, headers=headers, timeout=10)
1366
- results['tests']['agent_check'] = {
1367
- 'status': agent_resp.status_code,
1368
- 'exists': agent_resp.ok
1369
- }
1370
-
1371
- # Test 2: Check if we can get a signed URL for this agent. This is the most important test.
1372
- conv_url = f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id={AGENT_ID}"
1373
- conv_resp = requests.get(conv_url, headers=headers, timeout=10)
1374
- results['tests']['get_signed_url_check'] = {
1375
- 'status': conv_resp.status_code,
1376
- 'url_received': 'signed_url' in conv_resp.json() if conv_resp.ok else False
1377
- }
1378
-
1379
- return jsonify(results)
1380
-
1381
- except Exception as e:
1382
- return jsonify({'error': str(e), 'agent_id': AGENT_ID})
1383
 
1384
- @app.route('/api/projects/<project_id>/log-call-usage', methods=['POST'])
1385
- def log_call_usage(project_id):
1386
- """
1387
- ✅ MODIFIED: Now accepts and stores the full conversation transcript
1388
- in addition to calculating credit cost.
1389
- """
1390
- logger.info(f"[LOGGING] Received usage log for project: {project_id}")
1391
-
1392
  uid = verify_token(request.headers.get('Authorization'))
1393
- if not uid:
1394
- return jsonify({'error': 'Unauthorized'}), 401
1395
-
1396
  data = request.get_json()
1397
- duration_seconds = data.get("durationSeconds")
1398
- transcript = data.get("transcript") # Get the new transcript field
1399
-
1400
- if duration_seconds is None:
1401
- return jsonify({'error': 'Invalid duration provided.'}), 400
1402
-
1403
- # --- Credit Calculation ---
1404
- minutes = math.ceil(duration_seconds / 60)
1405
- cost = minutes * 3
1406
- logger.info(f"[LOGGING] User '{uid}' call duration: {duration_seconds:.2f}s, Cost: {cost} credits.")
1407
 
 
 
1408
  try:
1409
- # --- Transcript Storage ---
1410
- if transcript and isinstance(transcript, str) and len(transcript) > 10:
1411
- transcript_id = f"{project_id}_{int(time.time())}"
1412
- transcript_ref = db_ref.child(f'transcripts/{uid}/{transcript_id}')
1413
- transcript_data = {
1414
- "transcript": transcript,
1415
- "projectId": project_id,
1416
- "userId": uid,
1417
- "durationSeconds": duration_seconds,
1418
- "createdAt": datetime.utcnow().isoformat()
1419
- }
1420
- transcript_ref.set(transcript_data)
1421
- logger.info(f"[LOGGING] Successfully stored transcript {transcript_id} for user '{uid}'.")
1422
- else:
1423
- logger.warning(f"[LOGGING] No valid transcript provided for user '{uid}' on project {project_id}.")
1424
-
1425
- # --- Credit Deduction ---
1426
- user_ref = db_ref.child(f'users/{uid}')
1427
- user_data = user_ref.get()
1428
-
1429
- if user_data is None:
1430
- return jsonify({'error': 'User not found.'}), 404
1431
-
1432
- current_credits = user_data.get('credits', 0)
1433
- new_credits = max(0, current_credits - cost)
1434
- user_ref.update({'credits': new_credits})
1435
 
1436
- logger.info(f"[LOGGING] Updated credits for user '{uid}'. New balance: {new_credits}")
1437
  return jsonify({
1438
- "status": "success",
1439
- "creditsDeducted": cost,
1440
- "remainingCredits": new_credits
1441
  }), 200
1442
-
1443
  except Exception as e:
1444
- logger.error(f"[LOGGING] A database error occurred for user '{uid}': {e}")
1445
- return jsonify({'error': 'A server error occurred while updating credits.'}), 500
1446
-
1447
- #Stripe Payments
1448
- @app.route("/api/billing/config", methods=["GET"])
1449
- def get_stripe_config():
1450
- """
1451
- Returns safe-to-expose Stripe configuration values for the frontend.
1452
- No secret keys are exposed here.
1453
- """
1454
- try:
1455
- return jsonify({
1456
- "publishableKey": os.environ.get("STRIPE_PUBLISHABLE_KEY"),
1457
- "priceIds": {
1458
- "fixer": STRIPE_PRICE_FIXER,
1459
- "pro": STRIPE_PRICE_PRO,
1460
- }
1461
- }), 200
1462
- except Exception as e:
1463
- logger.error(f"[STRIPE] Failed to serve config: {e}")
1464
- return jsonify({"error": "Server configuration error"}), 500
1465
-
1466
-
1467
- @app.route("/api/billing/create-checkout-session", methods=["POST"])
1468
- def create_checkout_session():
1469
- """
1470
- Creates a Stripe Checkout Session for a recurring subscription.
1471
 
1472
- Client body:
1473
- { "plan": "standard" } -> The Fixer (200 credits/month)
1474
- { "plan": "premium" } -> The Pro (500 credits/month)
1475
- """
1476
- uid = verify_token(request.headers.get("Authorization"))
1477
- if not uid:
1478
- return jsonify({"error": "Unauthorized"}), 401
1479
-
1480
- if not STRIPE_SECRET_KEY or not STRIPE_PRICE_FIXER or not STRIPE_PRICE_PRO:
1481
- logger.error("[STRIPE] Missing Stripe configuration.")
1482
- return jsonify({"error": "Stripe is not configured on the server."}), 500
1483
-
1484
- data = request.get_json() or {}
1485
- plan_key = (data.get("plan") or "").lower().strip()
1486
-
1487
- plan_cfg = PLAN_CONFIG.get(plan_key)
1488
- if not plan_cfg:
1489
- return jsonify({"error": "Invalid plan selected."}), 400
1490
-
1491
- price_id = plan_cfg.get("price_id")
1492
- if not price_id or not price_id.startswith("price_"):
1493
- logger.error(f"[STRIPE] Misconfigured price_id for plan {plan_key}: {price_id}")
1494
- return jsonify({"error": "Billing configuration error – contact support."}), 500
1495
 
 
 
1496
  try:
1497
- customer_id = get_or_create_stripe_customer(uid)
1498
-
1499
- session = stripe.checkout.Session.create(
1500
- mode="subscription",
1501
- customer=customer_id,
1502
- payment_method_types=["card"],
1503
- line_items=[{
1504
- "price": price_id,
1505
- "quantity": 1,
1506
- }],
1507
- success_url=STRIPE_SUCCESS_URL + "?session_id={CHECKOUT_SESSION_ID}",
1508
- cancel_url=STRIPE_CANCEL_URL,
1509
- metadata={
1510
- "firebase_uid": uid,
1511
- "plan": plan_key,
1512
- },
1513
- subscription_data={
1514
- "metadata": {
1515
- "firebase_uid": uid,
1516
- "plan": plan_key,
1517
- }
1518
- },
1519
- )
1520
-
1521
- logger.info(
1522
- f"[STRIPE] Created checkout session {session.id} for user {uid}, plan {plan_key}"
1523
- )
1524
- return jsonify({
1525
- "id": session.id,
1526
- "url": session.url,
1527
- }), 200
1528
-
1529
- except Exception as e:
1530
- logger.error(f"[STRIPE] Error creating checkout session: {e}")
1531
- return jsonify({"error": "Failed to create checkout session."}), 500
1532
-
1533
- @app.route("/api/billing/webhook", methods=["POST"])
1534
- def stripe_webhook():
1535
- """
1536
- Handles Stripe webhook events:
1537
- - checkout.session.completed -> initial subscription + first credits
1538
- - invoice.payment_succeeded -> monthly renewals (reset credits)
1539
- - invoice.payment_failed -> mark planStatus as past_due/unpaid
1540
- - customer.subscription.deleted -> cancelled subscription
1541
- - customer.subscription.updated -> keep status/expiry in sync
1542
- """
1543
- if not STRIPE_WEBHOOK_SECRET:
1544
- logger.error("[STRIPE] STRIPE_WEBHOOK_SECRET not set.")
1545
- return jsonify({"error": "Webhook secret not configured."}), 500
1546
-
1547
- payload = request.data
1548
- sig_header = request.headers.get("Stripe-Signature")
1549
 
1550
- try:
1551
- event = stripe.Webhook.construct_event(
1552
- payload, sig_header, STRIPE_WEBHOOK_SECRET
1553
- )
1554
- except stripe.error.SignatureVerificationError as e:
1555
- logger.error(f"[STRIPE] Webhook signature verification failed: {e}")
1556
- return "Invalid signature", 400
 
 
 
1557
  except Exception as e:
1558
- logger.error(f"[STRIPE] Webhook parsing error: {e}")
1559
- return "Bad request", 400
1560
-
1561
- event_type = event.get("type")
1562
- data_obj = event.get("data", {}).get("object", {})
1563
-
1564
- logger.info(f"[STRIPE] Webhook event: {event_type}")
1565
-
1566
- try:
1567
- # 1) First payment via Checkout
1568
- if event_type == "checkout.session.completed":
1569
- session = data_obj
1570
- metadata = session.get("metadata") or {}
1571
- uid = metadata.get("firebase_uid")
1572
- plan_key = metadata.get("plan")
1573
- subscription_id = session.get("subscription")
1574
-
1575
- logger.info(
1576
- f"[STRIPE] checkout.session.completed uid={uid}, plan={plan_key}, sub={subscription_id}"
1577
- )
1578
-
1579
- if subscription_id:
1580
- sub = stripe.Subscription.retrieve(subscription_id)
1581
- update_user_subscription_from_stripe(sub)
1582
-
1583
- # First period: reset credits to plan allowance (no rollover)
1584
- if uid and plan_key:
1585
- reset_plan_credits(uid, plan_key)
1586
-
1587
- # 2) Every successful invoice (monthly renewal)
1588
- elif event_type == "invoice.payment_succeeded":
1589
- invoice = data_obj
1590
- subscription_id = invoice.get("subscription")
1591
-
1592
- if subscription_id:
1593
- sub = stripe.Subscription.retrieve(subscription_id)
1594
- metadata = sub.get("metadata") or {}
1595
- uid = metadata.get("firebase_uid")
1596
- plan_key = metadata.get("plan")
1597
-
1598
- logger.info(
1599
- f"[STRIPE] invoice.payment_succeeded uid={uid}, plan={plan_key}, sub={subscription_id}"
1600
- )
1601
-
1602
- update_user_subscription_from_stripe(sub)
1603
-
1604
- # New billing period: hard reset credits to monthly quota
1605
- if uid and plan_key:
1606
- reset_plan_credits(uid, plan_key)
1607
-
1608
- # 3) Invoice failed (non-payment) – mark plan as not good
1609
- elif event_type == "invoice.payment_failed":
1610
- invoice = data_obj
1611
- subscription_id = invoice.get("subscription")
1612
 
1613
- if subscription_id:
1614
- sub = stripe.Subscription.retrieve(subscription_id)
1615
- logger.info(
1616
- f"[STRIPE] invoice.payment_failed subscription={subscription_id}, status={sub.get('status')}"
1617
- )
1618
- # Status will now be past_due/unpaid; keep in sync
1619
- update_user_subscription_from_stripe(sub)
1620
 
1621
- # Optional: if you want to be harsh, you could also zero credits here.
1622
- # metadata = sub.get("metadata") or {}
1623
- # uid = metadata.get("firebase_uid")
1624
- # if uid:
1625
- # db_ref.child(f'users/{uid}').update({"credits": 0.0})
1626
- # logger.info(f"[STRIPE] Payment failed – zeroed credits for user {uid}")
1627
 
1628
- # 4) Subscription deleted (cancelled)
1629
- elif event_type == "customer.subscription.deleted":
1630
- sub = data_obj
1631
- logger.info(
1632
- f"[STRIPE] customer.subscription.deleted id={sub.get('id')}, status={sub.get('status')}"
1633
- )
1634
- update_user_subscription_from_stripe(sub)
 
 
 
 
 
1635
 
1636
- # 5) Subscription updated (status, period, etc.)
1637
- elif event_type == "customer.subscription.updated":
1638
- sub = data_obj
1639
- logger.info(
1640
- f"[STRIPE] customer.subscription.updated id={sub.get('id')}, status={sub.get('status')}"
1641
- )
1642
- update_user_subscription_from_stripe(sub)
1643
 
1644
- except Exception as e:
1645
- logger.error(f"[STRIPE] Error handling webhook {event_type}: {e}")
 
 
 
 
1646
 
1647
- return "", 200
1648
  # -----------------------------------------------------------------------------
1649
- # 7. MAIN EXECUTION
1650
  # -----------------------------------------------------------------------------
 
1651
  if __name__ == '__main__':
1652
- app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
 
5
  import time
6
  import json
7
  import traceback
8
+ import math
9
+ import requests
10
+ import threading
11
+ from concurrent.futures import ThreadPoolExecutor
12
  from datetime import datetime, timedelta
13
 
14
  from flask import Flask, request, jsonify, Response
 
16
  import firebase_admin
17
  from firebase_admin import credentials, db, storage, auth
18
  from PIL import Image
 
 
 
 
19
  from google import genai
20
  from google.genai import types
21
+
22
  # -----------------------------------------------------------------------------
23
  # 1. CONFIGURATION & INITIALIZATION
24
  # -----------------------------------------------------------------------------
25
 
 
26
  app = Flask(__name__)
27
  CORS(app)
28
 
 
36
  firebase_db_url = os.environ.get("Firebase_DB")
37
  firebase_storage_bucket = os.environ.get("Firebase_Storage")
38
 
 
 
 
39
  cred = credentials.Certificate(credentials_json)
40
  firebase_admin.initialize_app(cred, {
41
  'databaseURL': firebase_db_url,
 
46
  print(f"FATAL: Error initializing Firebase: {e}")
47
  exit(1)
48
 
 
49
  bucket = storage.bucket()
50
  db_ref = db.reference()
51
 
52
+ # --- Google GenAI Client Initialization (Gemini 3.0 Flash) ---
 
53
  try:
54
  api_key = os.environ.get("Gemini")
55
  if not api_key:
56
+ raise ValueError("The 'Gemini' API key is not set.")
57
 
58
  client = genai.Client(api_key=api_key)
59
+ print("Google GenAI (Gemini 3.0) Client initialized successfully.")
60
  except Exception as e:
61
  print(f"FATAL: Error initializing GenAI Client: {e}")
62
  exit(1)
63
 
64
+ # Model Constants
65
+ ATHENA_MODEL = "gemini-3-flash"
66
+ BRIEFING_MODEL = "gemini-3-flash"
 
 
 
 
 
 
 
67
 
68
+ # Grounding API Keys
69
+ WOLFRAM_APP_ID = os.environ.get("WOLFRAM_APP_ID")
70
+ OPENALEX_MAILTO = os.environ.get("OPENALEX_MAILTO", "rairo@sozofix.tech")
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  # -----------------------------------------------------------------------------
73
+ # 2. HELPER FUNCTIONS & GROUNDING
74
  # -----------------------------------------------------------------------------
75
 
76
  def verify_token(auth_header):
77
+ if not auth_header or not auth_header.startswith('Bearer '): return None
 
 
78
  token = auth_header.split('Bearer ')[1]
79
  try:
80
  decoded_token = auth.verify_id_token(token)
81
  return decoded_token['uid']
82
+ except: return None
 
 
83
 
84
  def verify_admin(auth_header):
 
85
  uid = verify_token(auth_header)
86
+ if not uid: raise PermissionError('Invalid or missing token')
87
+ user_data = db_ref.child(f'users/{uid}').get()
 
 
 
88
  if not user_data or not user_data.get('is_admin', False):
89
  raise PermissionError('Admin access required')
90
  return uid
91
 
92
  def upload_to_storage(data_bytes, destination_blob_name, content_type):
 
93
  blob = bucket.blob(destination_blob_name)
94
  blob.upload_from_string(data_bytes, content_type=content_type)
95
  blob.make_public()
96
  return blob.public_url
97
 
98
+ def query_wolfram_alpha(query):
99
+ if not WOLFRAM_APP_ID: return "Wolfram|Alpha grounding unavailable."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  try:
101
+ url = f"http://api.wolframalpha.com/v1/result?appid={WOLFRAM_APP_ID}&i={query}"
102
+ response = requests.get(url, timeout=5)
103
+ return response.text if response.status_code == 200 else "Fact-check pending."
104
+ except: return "Grounding timeout."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ def query_openalex(topic):
 
 
 
 
 
107
  try:
108
+ url = f"https://api.openalex.org/works?search={topic}&mailto={OPENALEX_MAILTO}"
109
+ resp = requests.get(url, timeout=5).json()
110
+ results = resp.get('results', [])
111
+ return [{"title": r['title'], "url": r['doi'] or r['id'], "year": r['publication_year']} for r in results[:3]]
112
+ except: return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  # -----------------------------------------------------------------------------
115
+ # 3. ASYNCHRONOUS VOICE ORCHESTRATION (ATHENA VOICE)
116
  # -----------------------------------------------------------------------------
117
 
118
+ def generate_single_narration(text, uid, epiphany_id, layer_name):
119
+ """Deepgram Aura-Luna generation for a single layer."""
 
 
 
 
120
  try:
121
+ api_key = os.environ.get("DEEPGRAM_API_KEY")
122
+ if not api_key: return layer_name, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
+ DEEPGRAM_URL = "https://api.deepgram.com/v1/speak?model=aura-luna-en"
125
+ headers = {"Authorization": f"Token {api_key}", "Content-Type": "text/plain"}
126
 
127
+ response = requests.post(DEEPGRAM_URL, headers=headers, data=text.encode('utf-8'))
128
+ response.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ audio_path = f"users/{uid}/epiphanies/{epiphany_id}/narrations/{layer_name}.mp3"
131
+ url = upload_to_storage(response.content, audio_path, 'audio/mpeg')
132
+ return layer_name, url
 
 
 
 
 
 
133
  except Exception as e:
134
+ print(f"TTS Error [{layer_name}]: {e}")
135
+ return layer_name, None
136
+
137
+ def generate_all_narrations_async(data_dict, uid, epiphany_id):
138
+ """Uses ThreadPoolExecutor to generate all 4 layers in parallel."""
139
+ layers = ['genesis', 'scientific_core', 'engineering_edge', 'cross_pollination']
140
+ results = {}
141
+ with ThreadPoolExecutor(max_workers=4) as executor:
142
+ futures = [executor.submit(generate_single_narration, data_dict[l], uid, epiphany_id, l) for l in layers]
143
+ for f in futures:
144
+ layer, url = f.result()
145
+ results[layer] = url
146
+ return results
147
 
148
  # -----------------------------------------------------------------------------
149
+ # 4. EPIPHANY SYNTHESIS (THE DISCOVERY ENDPOINTS)
150
  # -----------------------------------------------------------------------------
151
 
152
+ @app.route('/api/epiphany/generate', methods=['POST'])
153
+ def generate_epiphany():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  uid = verify_token(request.headers.get('Authorization'))
155
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
156
 
157
  user_ref = db_ref.child(f'users/{uid}')
158
  user_data = user_ref.get()
159
  if not user_data or user_data.get('credits', 0) < 1:
160
+ return jsonify({'error': 'Insufficient sparks for an Epiphany.'}), 402
161
 
162
  if 'image' not in request.files:
163
+ return jsonify({'error': 'Visual input is required.'}), 400
164
 
165
  image_file = request.files['image']
 
166
  image_bytes = image_file.read()
167
+ pil_image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
168
 
169
  try:
170
+ # Step 1: Rapid Identification
171
+ id_prompt = "Identify this precisely. If biological, include the Latin name. If mechanical, include the inventor if known. Reply with ONLY the name."
172
+ subject = client.models.generate_content(model=ATHENA_MODEL, contents=[id_prompt, pil_image]).text.strip()
173
+
174
+ # Step 2: Grounding (Parallel Dispatch)
175
+ physics_fact = query_wolfram_alpha(f"constants of {subject}")
176
+ papers = query_openalex(subject)
177
+
178
+ # Step 3: Synthesis (Feynman Technique)
179
+ synthesis_prompt = f"""
180
+ Act as Athena, the Systems Synthesizer. Reveal the first principles of '{subject}'.
181
+ Style: Richard Feynman. Simple analogies, profound scientific truths.
182
+
183
+ Grounding: {physics_fact}
184
+
185
+ Output JSON:
186
+ {{
187
+ "title": "Epic 3-word title",
188
+ "genesis": "The origin and the core 'Aha!' moment of this system.",
189
+ "scientific_core": "The first principles of physics/biology at play here.",
190
+ "engineering_edge": "Detailed analysis of material, stress, or evolutionary advantage.",
191
+ "cross_pollination": "How this principle applies to a completely different field."
192
+ }}
 
 
193
  """
 
 
194
 
195
+ synth_response = client.models.generate_content(
196
+ model=ATHENA_MODEL,
197
+ contents=[synthesis_prompt, pil_image],
198
+ config=types.GenerateContentConfig(response_mime_type='application/json')
199
+ )
 
 
200
 
201
+ data = json.loads(synth_response.text)
202
+ epiphany_id = str(uuid.uuid4())
203
+
204
+ # Step 4: Parallel Audio Generation (Hackathon Latency Optimization)
205
+ narration_urls = generate_all_narrations_async(data, uid, epiphany_id)
206
+
207
+ # Step 5: Persistence
208
+ image_url = upload_to_storage(image_bytes, f"users/{uid}/epiphanies/{epiphany_id}/vision.jpg", 'image/jpeg')
209
+
210
+ epiphany_record = {
211
+ "epiphanyId": epiphany_id,
212
+ "uid": uid,
213
+ "title": data['title'],
214
+ "subject": subject,
215
+ "imageURL": image_url,
216
+ "layers": {
217
+ "genesis": {"text": data['genesis'], "audio": narration_urls.get('genesis')},
218
+ "scientific_core": {"text": data['scientific_core'], "audio": narration_urls.get('scientific_core')},
219
+ "engineering_edge": {"text": data['engineering_edge'], "audio": narration_urls.get('engineering_edge')},
220
+ "cross_pollination": {"text": data['cross_pollination'], "audio": narration_urls.get('cross_pollination')}
221
+ },
222
+ "grounding": {"physics": physics_fact, "papers": papers},
223
+ "createdAt": datetime.utcnow().isoformat()
224
  }
 
225
 
226
+ db_ref.child(f'epiphanies/{epiphany_id}').set(epiphany_record)
227
  user_ref.update({'credits': user_data.get('credits', 0) - 1})
228
+
229
+ return jsonify(epiphany_record), 201
230
 
231
  except Exception as e:
232
  print(traceback.format_exc())
233
+ return jsonify({'error': str(e)}), 500
 
234
 
235
+ @app.route('/api/epiphany/deep-dive', methods=['POST'])
236
+ def deep_dive():
237
+ """Zoom-based recursive reasoning."""
 
 
 
 
238
  uid = verify_token(request.headers.get('Authorization'))
239
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ image_file = request.files['image']
242
+ pil_image = Image.open(io.BytesIO(image_file.read())).convert('RGB')
 
 
 
 
 
 
 
243
 
244
+ dive_prompt = "Act as an Engineering Lead. In 50 words, explain the microscopic or mechanical significance of this specific detail."
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
 
246
  try:
247
+ res = client.models.generate_content(model=ATHENA_MODEL, contents=[dive_prompt, pil_image])
248
+ user_ref = db_ref.child(f'users/{uid}')
249
+ user_ref.update({'credits': max(0, user_ref.get().get('credits', 0) - 1)})
250
+ return jsonify({"analysis": res.text.strip()}), 200
251
  except Exception as e:
252
+ return jsonify({'error': str(e)}), 500
 
 
 
 
253
 
254
+ # -----------------------------------------------------------------------------
255
+ # 5. THE CHIRON MENTOR (INTERACTION & MEMORY)
256
+ # -----------------------------------------------------------------------------
 
 
 
 
257
 
258
+ @app.route('/api/user/call-briefing', methods=['GET'])
259
+ def get_chiron_briefing():
260
+ """Enhanced Briefing: Passes grounding papers to ElevenLabs context."""
261
+ uid = verify_token(request.headers.get('Authorization'))
262
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
 
 
 
 
 
 
 
 
263
 
264
  try:
265
+ # Get last Epiphany for context
266
+ last_epiphany = db_ref.child('epiphanies').order_by_child('uid').equal_to(uid).limit_to_last(1).get() or {}
267
+
268
+ context_data = ""
269
+ if last_epiphany:
270
+ eid = list(last_epiphany.keys())[0]
271
+ e_data = last_epiphany[eid]
272
+ papers = e_data.get('grounding', {}).get('papers', [])
273
+ paper_titles = [p['title'] for p in papers]
274
+ context_data = f"Current focus: {e_data['subject']}. Recent papers unlocked: {', '.join(paper_titles)}."
275
+
276
+ brief_prompt = f"""
277
+ Prep Chiron, the Socratic Mentor.
278
+ User Context: {context_data}
279
+ Write a 4-sentence brief for Chiron. He must reference the scientific papers or topics the user just explored.
280
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
+ response = client.models.generate_content(model=BRIEFING_MODEL, contents=[brief_prompt])
283
+ return jsonify({"memory_summary": response.text.strip(), "grounding_context": context_data}), 200
284
  except Exception as e:
285
+ return jsonify({'error': str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
+ @app.route('/api/log-call-usage', methods=['POST'])
288
+ def log_call_usage():
289
  uid = verify_token(request.headers.get('Authorization'))
290
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
 
 
 
291
 
 
 
 
 
292
  data = request.get_json()
293
+ duration = data.get("durationSeconds", 0)
294
+ transcript = data.get("transcript", "")
 
 
 
 
 
 
 
 
 
 
 
295
 
296
+ cost = math.ceil(duration / 60) * 3
 
 
 
 
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  try:
299
+ user_ref = db_ref.child(f'users/{uid}')
300
+ new_bal = max(0, user_ref.get().get('credits', 0) - cost)
301
+ user_ref.update({'credits': new_bal})
 
 
 
 
302
 
303
+ if transcript:
304
+ db_ref.child(f'transcripts/{uid}').push({
305
+ "text": transcript,
306
+ "createdAt": datetime.utcnow().isoformat()
 
 
307
  })
308
+ return jsonify({"success": True, "remainingCredits": new_bal}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  except Exception as e:
310
+ return jsonify({'error': str(e)}), 500
 
311
 
312
+ # -----------------------------------------------------------------------------
313
+ # 6. ADMIN, FEEDBACK & CREDITS
314
+ # -----------------------------------------------------------------------------
315
 
316
+ @app.route('/api/feedback', methods=['POST'])
317
+ def submit_feedback():
 
 
 
 
 
 
318
  uid = verify_token(request.headers.get('Authorization'))
319
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
320
+ data = request.get_json()
321
+ fb_ref = db_ref.child('feedback').push()
322
+ fb_ref.set({
323
+ "userId": uid,
324
+ "message": data.get('message'),
325
+ "type": data.get('type', 'general'),
326
+ "status": "open",
327
+ "createdAt": datetime.utcnow().isoformat()
328
+ })
329
+ return jsonify({"success": True}), 201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
+ @app.route('/api/user/request-credits', methods=['POST'])
332
+ def request_credits():
 
 
 
 
 
 
333
  uid = verify_token(request.headers.get('Authorization'))
334
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
 
335
  data = request.get_json()
336
+ req_ref = db_ref.child('credit_requests').push()
337
+ req_ref.set({
338
+ "userId": uid,
339
+ "requested_amount": data.get('amount', 50),
340
+ "status": "pending",
341
+ "createdAt": datetime.utcnow().isoformat()
342
+ })
343
+ return jsonify({"success": True}), 201
 
 
344
 
345
+ @app.route('/api/admin/dashboard', methods=['GET'])
346
+ def admin_dashboard():
347
  try:
348
+ verify_admin(request.headers.get('Authorization'))
349
+ users = db_ref.child('users').get() or {}
350
+ epiphanies = db_ref.child('epiphanies').get() or {}
351
+ requests = db_ref.child('credit_requests').get() or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
 
353
  return jsonify({
354
+ "total_users": len(users),
355
+ "total_epiphanies": len(epiphanies),
356
+ "pending_requests": len([r for r in requests.values() if r.get('status') == 'pending'])
357
  }), 200
 
358
  except Exception as e:
359
+ return jsonify({'error': str(e)}), 403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
+ # -----------------------------------------------------------------------------
362
+ # 7. AUTHENTICATION & PROFILE
363
+ # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
+ @app.route('/api/auth/signup', methods=['POST'])
366
+ def signup():
367
  try:
368
+ data = request.get_json()
369
+ email, password = data.get('email'), data.get('password')
370
+ display_name = data.get('displayName', 'Seeker')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
+ user = auth.create_user(email=email, password=password, display_name=display_name)
373
+ user_data = {
374
+ 'email': email,
375
+ 'displayName': display_name,
376
+ 'credits': 30, # Bumped to 30 for Athena Seeker
377
+ 'is_admin': False,
378
+ 'createdAt': datetime.utcnow().isoformat()
379
+ }
380
+ db_ref.child(f'users/{user.uid}').set(user_data)
381
+ return jsonify({'uid': user.uid, **user_data}), 201
382
  except Exception as e:
383
+ return jsonify({'error': str(e)}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
+ @app.route('/api/auth/social-signin', methods=['POST'])
386
+ def social_signin():
387
+ uid = verify_token(request.headers.get('Authorization'))
388
+ if not uid: return jsonify({'error': 'Invalid token'}), 401
 
 
 
389
 
390
+ user_ref = db_ref.child(f'users/{uid}')
391
+ user_data = user_ref.get()
 
 
 
 
392
 
393
+ if not user_data:
394
+ firebase_user = auth.get_user(uid)
395
+ user_data = {
396
+ 'email': firebase_user.email,
397
+ 'displayName': firebase_user.display_name or 'Seeker',
398
+ 'credits': 30,
399
+ 'is_admin': False,
400
+ 'createdAt': datetime.utcnow().isoformat()
401
+ }
402
+ user_ref.set(user_data)
403
+
404
+ return jsonify({'uid': uid, **user_data}), 200
405
 
406
+ @app.route('/api/user/profile', methods=['GET'])
407
+ def get_profile():
408
+ uid = verify_token(request.headers.get('Authorization'))
409
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
410
+ return jsonify(db_ref.child(f'users/{uid}').get())
 
 
411
 
412
+ @app.route('/api/epiphanies', methods=['GET'])
413
+ def list_epiphanies():
414
+ uid = verify_token(request.headers.get('Authorization'))
415
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
416
+ results = db_ref.child('epiphanies').order_by_child('uid').equal_to(uid).get() or {}
417
+ return jsonify(list(results.values()))
418
 
 
419
  # -----------------------------------------------------------------------------
420
+ # 8. MAIN EXECUTION
421
  # -----------------------------------------------------------------------------
422
+
423
  if __name__ == '__main__':
424
+ app.run(debug=False, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))