rairo commited on
Commit
6bc6aa5
·
verified ·
1 Parent(s): 6ce11bc

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +712 -828
main.py CHANGED
@@ -20,6 +20,8 @@ from elevenlabs import ElevenLabs
20
  from google import genai
21
  from google.genai import types
22
  import stripe
 
 
23
  # -----------------------------------------------------------------------------
24
  # 1. CONFIGURATION & INITIALIZATION
25
  # -----------------------------------------------------------------------------
@@ -56,7 +58,7 @@ 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:
@@ -68,22 +70,33 @@ 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"
@@ -97,8 +110,25 @@ 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):
@@ -149,7 +179,7 @@ def _convert_pcm_to_wav(pcm_data, sample_rate=24000, channels=1, sample_width=2)
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):
@@ -186,49 +216,27 @@ def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
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
@@ -242,325 +250,262 @@ def send_text_request(model_name, prompt, image):
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}")
@@ -569,50 +514,32 @@ def social_signin():
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
  # -----------------------------------------------------------------------------
@@ -620,25 +547,23 @@ def update_user_profile():
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
@@ -646,40 +571,87 @@ def submit_feedback():
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
@@ -690,78 +662,60 @@ def get_admin_profile():
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:
@@ -775,20 +729,19 @@ def list_credit_requests():
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:
@@ -808,8 +761,7 @@ 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
 
@@ -818,28 +770,237 @@ 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
@@ -847,10 +1008,10 @@ def create_project():
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 = (
@@ -862,7 +1023,8 @@ def create_project():
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}.
@@ -880,28 +1042,34 @@ def create_project():
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
 
@@ -914,101 +1082,74 @@ def create_project():
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:
@@ -1019,199 +1160,91 @@ def approve_project_plan(project_id):
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
@@ -1220,243 +1253,179 @@ def get_project(project_id):
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:
@@ -1466,187 +1435,102 @@ def get_stripe_config():
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)))
 
20
  from google import genai
21
  from google.genai import types
22
  import stripe
23
+ import math
24
+
25
  # -----------------------------------------------------------------------------
26
  # 1. CONFIGURATION & INITIALIZATION
27
  # -----------------------------------------------------------------------------
 
58
  db_ref = db.reference()
59
 
60
 
61
+ # --- Google GenAI Client Initialization ---
62
  try:
63
  api_key = os.environ.get("Gemini")
64
  if not api_key:
 
70
  print(f"FATAL: Error initializing GenAI Client: {e}")
71
  exit(1)
72
 
73
+ # ---------------------------------------------------------------------------
74
+ # MODEL CONSTANTS – Updated May 2026
75
+ #
76
+ # CATEGORY_MODEL : fast vision + text classification
77
+ # gemini-3.1-flash-lite (Gemini 3.1 Flash-Lite,
78
+ # Google's most cost-efficient model as of May 2026)
79
+ #
80
+ # GENERATION_MODEL : multimodal image+text generation
81
+ # → gemini-3.1-flash-image-preview (Nano Banana 2,
82
+ # launched Feb 26 2026 as Gemini 3.1 Flash Image)
83
+ #
84
+ # SUMMARY_MODEL : lightweight text analysis for call briefings
85
+ # → gemini-2.5-flash-lite (replaces 2.0-flash-lite
86
+ # which shuts down June 1 2026)
87
+ #
88
+ # All changes are backwards-compatible – the client contract is unchanged.
89
+ # ---------------------------------------------------------------------------
90
+ CATEGORY_MODEL = "gemini-3.1-flash-lite" # was gemini-2.5-flash
91
+ GENERATION_MODEL = "gemini-3.1-flash-image-preview" # Nano Banana 2 (was gemini-2.0-flash-exp-image-generation)
92
+ SUMMARY_MODEL = "gemini-3.1-flash-lite" # was gemini-2.0-flash-lite
93
 
 
94
  # --- Stripe Initialization ---
95
+ STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
96
  STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET")
97
+ STRIPE_PRICE_FIXER = os.environ.get("STRIPE_PRICE_FIXER")
98
+ STRIPE_PRICE_PRO = os.environ.get("STRIPE_PRICE_PRO")
99
 
 
 
 
 
 
100
  STRIPE_SUCCESS_URL = os.environ.get(
101
  "STRIPE_SUCCESS_URL",
102
  "https://sozofix.tech/billing/success"
 
110
  stripe.api_key = STRIPE_SECRET_KEY
111
  else:
112
  print("WARNING: STRIPE_SECRET_KEY is not set – Stripe endpoints will fail.")
113
+
114
+ # --- Resend Initialization ---
115
+ RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
116
+ RESEND_FROM_EMAIL = os.environ.get("RESEND_FROM_EMAIL", "SozoFix <noreply@sozofix.tech>")
117
+
118
+ if not RESEND_API_KEY:
119
+ print("WARNING: RESEND_API_KEY is not set – email notification endpoints will fail.")
120
+
121
+ # --- ElevenLabs ---
122
+ ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
123
+ AGENT_ID = os.environ.get("ELEVENLABS_AGENT_ID")
124
+
125
+ import logging
126
+
127
+ logging.basicConfig(level=logging.INFO)
128
+ logger = logging.getLogger(__name__)
129
+
130
  # -----------------------------------------------------------------------------
131
+ # 2. HELPER FUNCTIONS
132
  # -----------------------------------------------------------------------------
133
 
134
  def verify_token(auth_header):
 
179
  audio_buffer.seek(0)
180
  return audio_buffer.getvalue()
181
 
182
+
183
  #Gemini tts implementation SOTA but slow
184
  '''
185
  def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
 
216
 
217
  # DeepGram faster and efficient
218
  def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
219
+ """Generates audio using Deepgram TTS and uploads it to Firebase Storage."""
 
 
 
220
  try:
 
221
  api_key = os.environ.get("DEEPGRAM_API_KEY")
222
  if not api_key:
223
  print("FATAL: DEEPGRAM_API_KEY environment variable not set.")
224
  return None
225
 
 
 
226
  DEEPGRAM_URL = "https://api.deepgram.com/v1/speak?model=aura-2-draco-en"
 
227
  headers = {
228
  "Authorization": f"Token {api_key}",
229
+ "Content-Type": "text/plain"
230
  }
 
 
 
 
231
  response = requests.post(DEEPGRAM_URL, headers=headers, data=text_to_speak.encode('utf-8'))
 
 
232
  response.raise_for_status()
233
 
 
234
  audio_data = response.content
 
 
 
235
  audio_path = f"users/{uid}/projects/{project_id}/narrations/step_{step_num}.mp3"
236
+ return upload_to_storage(audio_data, audio_path, 'audio/mpeg')
 
 
 
 
237
 
238
  except requests.exceptions.RequestException as e:
239
  print(f"Error during Deepgram API call for step {step_num}: {e}")
 
240
  if e.response is not None:
241
  print(f"Deepgram Error Response: {e.response.text}")
242
  return None
 
250
  try:
251
  chat = client.chats.create(model=model_name)
252
  response = chat.send_message([prompt, image])
253
+ response_text = "".join(
254
+ part.text for part in response.candidates[0].content.parts if hasattr(part, 'text')
255
+ )
256
  return response_text.strip()
257
  except Exception as e:
258
  print(f"Error with model {model_name}: {e}")
259
  return None
260
 
261
 
262
+ # ---------------------------------------------------------------------------
263
+ # RESEND EMAIL HELPER
264
+ # ---------------------------------------------------------------------------
265
+
266
+ def send_email_via_resend(to_emails: list, subject: str, html_body: str) -> dict:
267
+ """
268
+ Sends an email using the Resend API.
269
+
270
+ Args:
271
+ to_emails: List of recipient email addresses.
272
+ subject: Email subject line.
273
+ html_body: HTML content of the email.
274
+
275
+ Returns:
276
+ dict with keys 'success' (bool), 'id' (str or None), 'error' (str or None).
277
+ """
278
+ if not RESEND_API_KEY:
279
+ return {"success": False, "id": None, "error": "RESEND_API_KEY not configured."}
280
+
281
+ payload = {
282
+ "from": RESEND_FROM_EMAIL,
283
+ "to": to_emails,
284
+ "subject": subject,
285
+ "html": html_body,
286
+ }
287
+
288
+ try:
289
+ resp = requests.post(
290
+ "https://api.resend.com/emails",
291
+ headers={
292
+ "Authorization": f"Bearer {RESEND_API_KEY}",
293
+ "Content-Type": "application/json",
294
+ },
295
+ json=payload,
296
+ timeout=15,
297
+ )
298
+ resp.raise_for_status()
299
+ data = resp.json()
300
+ logger.info(f"[RESEND] Email sent successfully. id={data.get('id')} to={to_emails}")
301
+ return {"success": True, "id": data.get("id"), "error": None}
302
+ except requests.exceptions.RequestException as e:
303
+ body = e.response.text if e.response is not None else str(e)
304
+ logger.error(f"[RESEND] Failed to send email: {body}")
305
+ return {"success": False, "id": None, "error": body}
306
+
307
+
308
+ def _notification_email_html(title: str, message: str) -> str:
309
+ """Returns a clean branded HTML template for SozoFix notifications."""
310
+ return f"""
311
+ <!DOCTYPE html>
312
+ <html>
313
+ <head>
314
+ <meta charset="UTF-8">
315
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
316
+ </head>
317
+ <body style="margin:0;padding:0;background:#f4f4f5;font-family:'Segoe UI',Arial,sans-serif;">
318
+ <table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
319
+ <tr>
320
+ <td align="center">
321
+ <table width="600" cellpadding="0" cellspacing="0"
322
+ style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
323
+ <!-- Header -->
324
+ <tr>
325
+ <td style="background:#1a1a2e;padding:28px 40px;text-align:center;">
326
+ <h1 style="color:#e8c33a;margin:0;font-size:24px;letter-spacing:1px;">🔧 SozoFix</h1>
327
+ <p style="color:#a0aec0;margin:6px 0 0;font-size:13px;">DIY Intelligence Platform</p>
328
+ </td>
329
+ </tr>
330
+ <!-- Body -->
331
+ <tr>
332
+ <td style="padding:40px;">
333
+ <h2 style="color:#1a1a2e;margin:0 0 16px;font-size:20px;">{title}</h2>
334
+ <div style="color:#4a5568;font-size:15px;line-height:1.7;white-space:pre-wrap;">{message}</div>
335
+ </td>
336
+ </tr>
337
+ <!-- Footer -->
338
+ <tr>
339
+ <td style="background:#f7f7f8;padding:20px 40px;text-align:center;border-top:1px solid #e2e8f0;">
340
+ <p style="color:#a0aec0;font-size:12px;margin:0;">
341
+ © {datetime.utcnow().year} SozoFix · You received this because you have an account on sozofix.tech
342
+ </p>
343
+ </td>
344
+ </tr>
345
+ </table>
346
+ </td>
347
+ </tr>
348
+ </table>
349
+ </body>
350
+ </html>
351
+ """
352
 
 
 
 
353
 
 
354
  # Stripe plan config
355
  PLAN_CONFIG = {
356
+ "standard": {"price_id": STRIPE_PRICE_FIXER, "credits": 200, "label": "The Fixer"},
357
+ "fixer": {"price_id": STRIPE_PRICE_FIXER, "credits": 200, "label": "The Fixer"},
358
+ "premium": {"price_id": STRIPE_PRICE_PRO, "credits": 500, "label": "The Pro"},
359
+ "pro": {"price_id": STRIPE_PRICE_PRO, "credits": 500, "label": "The Pro"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  }
361
 
362
  def get_or_create_stripe_customer(uid: str) -> str:
363
+ user_ref = db_ref.child(f'users/{uid}')
 
 
 
 
364
  user_data = user_ref.get() or {}
 
365
  existing_id = user_data.get("stripeCustomerId")
366
  if existing_id:
367
  return existing_id
368
+ email = user_data.get("email")
369
+ customer = stripe.Customer.create(email=email, metadata={"firebase_uid": uid})
 
 
 
 
 
370
  user_ref.update({"stripeCustomerId": customer.id})
371
  return customer.id
372
 
373
  def reset_plan_credits(uid: str, plan_key: str):
 
 
 
 
 
 
 
 
374
  plan_cfg = PLAN_CONFIG.get(plan_key)
375
  if not plan_cfg:
376
  logger.error(f"[STRIPE] Unknown plan '{plan_key}' for user {uid}")
377
  return
 
378
  monthly_credits = float(plan_cfg["credits"])
379
+ user_ref = db_ref.child(f'users/{uid}')
 
380
  user_data = user_ref.get() or {}
381
+ previous = user_data.get('credits', 0)
 
382
  user_ref.update({
383
  "credits": monthly_credits,
384
  "lastCreditResetAt": datetime.utcnow().isoformat(),
385
  "creditResetPlan": plan_key,
386
  })
 
387
  logger.info(
388
  f"[STRIPE] RESET credits for user {uid} to {monthly_credits} "
389
+ f"for plan '{plan_key}'. Previous balance: {previous}"
390
  )
391
 
392
  def update_user_subscription_from_stripe(subscription: dict):
393
+ metadata = subscription.get("metadata") or {}
394
+ uid = metadata.get("firebase_uid")
395
+ plan_key = metadata.get("plan")
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  if not uid or not plan_key:
397
  logger.error("[STRIPE] Subscription missing firebase_uid or plan in metadata.")
398
  return
399
+ plan_cfg = PLAN_CONFIG.get(plan_key, {})
400
+ plan_label = plan_cfg.get("label", plan_key.title())
 
401
  monthly_credits = plan_cfg.get("credits", 0)
402
+ status = subscription.get("status")
403
+ current_period_end = subscription.get("current_period_end")
404
+ expiry_iso = datetime.utcfromtimestamp(current_period_end).isoformat() + "Z" if current_period_end else None
 
 
 
 
 
 
405
  user_ref = db_ref.child(f'users/{uid}')
406
+ user_ref.update({
407
+ "currentPlan": plan_key,
408
+ "currentPlanLabel": plan_label,
409
+ "currentPlanMonthlyCredits": monthly_credits,
410
+ "planStatus": status,
411
+ "planCurrentPeriodEnd": expiry_iso,
412
+ "stripeSubscriptionId": subscription.get("id"),
413
+ "planUpdatedAt": datetime.utcnow().isoformat(),
414
+ })
415
+ logger.info(f"[STRIPE] Updated subscription for user {uid}: plan={plan_key}, status={status}")
416
+
417
 
 
 
 
 
 
418
  # =============================================================================
419
+ # IMAGE PROXY ENDPOINT (NO AUTHENTICATION)
420
  # =============================================================================
421
  @app.route('/api/image-proxy', methods=['GET'])
422
  def image_proxy():
423
  image_url = request.args.get('url')
424
  logger.info(f"[IMAGE PROXY] Received URL: {image_url}")
 
425
  if not image_url:
 
426
  return jsonify({'error': 'URL parameter is missing.'}), 400
 
427
  try:
 
 
428
  if 'storage.googleapis.com' not in image_url:
 
429
  return jsonify({'error': 'Invalid Firebase Storage URL.'}), 400
430
+ url_parts = image_url.split('storage.googleapis.com/')[1].split('?')[0]
 
 
 
 
 
 
 
 
 
 
 
431
  path_components = url_parts.split('/', 1)
 
 
432
  if len(path_components) < 2:
 
433
  return jsonify({'error': 'Invalid URL format.'}), 400
434
+ url_bucket_name, blob_path = path_components
435
+ if url_bucket_name != bucket.name:
 
 
 
 
 
 
 
 
 
 
 
436
  return jsonify({'error': 'Bucket name mismatch.'}), 403
 
 
 
 
437
  blob = bucket.blob(blob_path)
 
 
438
  if not blob.exists():
 
439
  return jsonify({'error': 'Image not found.'}), 404
440
+ image_bytes = blob.download_as_bytes()
 
 
 
441
  content_type = blob.content_type or 'application/octet-stream'
 
 
 
 
442
  response = Response(image_bytes, content_type=content_type)
443
+ response.headers['Cache-Control'] = 'public, max-age=3600'
444
  return response
 
 
 
 
 
445
  except Exception as e:
446
+ logger.error(f"[IMAGE PROXY] Unexpected error: {traceback.format_exc()}")
 
 
 
 
 
447
  return jsonify({'error': 'Internal server error processing the image request.'}), 500
448
 
449
+
450
  # -----------------------------------------------------------------------------
451
+ # 3. AUTHENTICATION & USER MANAGEMENT
452
  # -----------------------------------------------------------------------------
453
 
454
  @app.route('/api/auth/signup', methods=['POST'])
455
  def signup():
 
 
 
 
456
  try:
457
+ data = request.get_json()
458
+ email = data.get('email')
459
+ password = data.get('password')
460
+ display_name = data.get('displayName')
 
461
  if not email or not password:
462
  return jsonify({'error': 'Email and password are required'}), 400
463
+ user = auth.create_user(email=email, password=password, display_name=display_name)
464
+ user_ref = db_ref.child(f'users/{user.uid}')
 
 
 
 
 
 
 
 
465
  user_data = {
466
+ 'email': email,
467
+ 'displayName': display_name,
468
+ 'credits': 15,
469
+ 'is_admin': False,
470
+ 'createdAt': datetime.utcnow().isoformat()
471
  }
472
  user_ref.set(user_data)
473
+ logger.info(f"New user signed up: {user.uid}")
 
474
  return jsonify({'success': True, 'uid': user.uid, **user_data}), 201
 
475
  except Exception as e:
476
  logger.error(f"Signup failed: {e}")
 
477
  if 'EMAIL_EXISTS' in str(e):
478
  return jsonify({'error': 'An account with this email already exists.'}), 409
479
  return jsonify({'error': str(e)}), 400
480
 
481
  @app.route('/api/auth/social-signin', methods=['POST'])
482
  def social_signin():
 
 
 
 
 
483
  uid = verify_token(request.headers.get('Authorization'))
484
  if not uid:
485
  return jsonify({'error': 'Invalid or expired token'}), 401
486
+ user_ref = db_ref.child(f'users/{uid}')
 
487
  user_data = user_ref.get()
 
488
  if user_data:
 
489
  if 'displayName' not in user_data or user_data['displayName'] is None:
490
  try:
 
491
  firebase_user = auth.get_user(uid)
492
  if firebase_user.display_name:
 
493
  user_ref.update({'displayName': firebase_user.display_name})
 
494
  user_data = user_ref.get()
495
  except Exception as e:
496
  logger.error(f"Could not backfill displayName for user {uid}: {e}")
 
497
  return jsonify({'uid': uid, **user_data}), 200
498
  else:
 
 
499
  try:
500
  firebase_user = auth.get_user(uid)
501
  new_user_data = {
502
+ 'email': firebase_user.email,
503
  'displayName': firebase_user.display_name,
504
+ 'credits': 15,
505
+ 'is_admin': False,
506
+ 'createdAt': datetime.utcnow().isoformat()
507
  }
508
  user_ref.set(new_user_data)
 
 
509
  return jsonify({'success': True, 'uid': uid, **new_user_data}), 201
510
  except Exception as e:
511
  logger.error(f"Error creating profile for new social user {uid}: {e}")
 
514
 
515
  @app.route('/api/user/profile', methods=['GET'])
516
  def get_user_profile():
 
 
 
 
517
  uid = verify_token(request.headers.get('Authorization'))
518
  if not uid:
519
  return jsonify({'error': 'Invalid or expired token'}), 401
 
520
  user_data = db_ref.child(f'users/{uid}').get()
521
  if not user_data:
522
  return jsonify({'error': 'User not found'}), 404
 
523
  return jsonify({'uid': uid, **user_data})
524
 
525
  @app.route('/api/user/profile', methods=['PUT'])
526
  def update_user_profile():
 
 
 
527
  uid = verify_token(request.headers.get('Authorization'))
528
  if not uid:
529
  return jsonify({'error': 'Invalid or expired token'}), 401
530
+ data = request.get_json()
 
531
  new_display_name = data.get('displayName')
 
532
  if not new_display_name or not isinstance(new_display_name, str) or len(new_display_name.strip()) == 0:
533
  return jsonify({'error': 'A valid displayName is required.'}), 400
 
534
  try:
 
535
  auth.update_user(uid, display_name=new_display_name)
536
+ db_ref.child(f'users/{uid}').update({'displayName': new_display_name})
 
 
 
 
 
537
  return jsonify({'success': True, 'message': 'Profile updated successfully.'}), 200
 
538
  except Exception as e:
539
  logger.error(f"Error updating profile for user {uid}: {e}")
540
  return jsonify({'error': f'Failed to update profile: {str(e)}'}), 500
541
+
542
+
543
  # -----------------------------------------------------------------------------
544
  # 4. FEEDBACK AND CREDIT REQUESTS (USER-FACING)
545
  # -----------------------------------------------------------------------------
 
547
  @app.route('/api/feedback', methods=['POST'])
548
  def submit_feedback():
549
  uid = verify_token(request.headers.get('Authorization'))
550
+ if not uid:
551
+ return jsonify({'error': 'Invalid or expired token'}), 401
552
  try:
553
  data = request.get_json()
554
+ if not data or not data.get('message'):
555
+ return jsonify({'error': 'Message is required'}), 400
556
+ user_email = (db_ref.child(f'users/{uid}').get() or {}).get('email', 'unknown')
 
557
  feedback_ref = db_ref.child('feedback').push()
558
+ feedback_ref.set({
559
  "feedbackId": feedback_ref.key,
560
+ "userId": uid,
561
+ "userEmail": user_email,
562
+ "type": data.get('type', 'general'),
563
+ "message": data.get('message'),
564
+ "createdAt": datetime.utcnow().isoformat(),
565
+ "status": "open"
566
+ })
 
567
  return jsonify({"success": True, "feedbackId": feedback_ref.key}), 201
568
  except Exception as e:
569
  return jsonify({'error': str(e)}), 500
 
571
  @app.route('/api/user/request-credits', methods=['POST'])
572
  def request_credits():
573
  uid = verify_token(request.headers.get('Authorization'))
574
+ if not uid:
575
+ return jsonify({'error': 'Invalid or expired token'}), 401
576
  try:
577
  data = request.get_json()
578
+ if not data or 'requested_credits' not in data:
579
+ return jsonify({'error': 'requested_credits is required'}), 400
580
  request_ref = db_ref.child('credit_requests').push()
581
  request_ref.set({
582
+ 'requestId': request_ref.key,
583
+ 'userId': uid,
584
  'requested_credits': data['requested_credits'],
585
+ 'status': 'pending',
586
+ 'requestedAt': datetime.utcnow().isoformat()
587
  })
588
  return jsonify({'success': True, 'requestId': request_ref.key})
589
  except Exception as e:
590
  return jsonify({'error': str(e)}), 500
591
 
592
  # -----------------------------------------------------------------------------
593
+ # 5. USER-FACING NOTIFICATIONS ENDPOINT
594
+ # -----------------------------------------------------------------------------
595
+
596
+ @app.route('/api/user/notifications', methods=['GET'])
597
+ def get_user_notifications():
598
+ """
599
+ Returns the authenticated user's notifications, ordered newest first.
600
+ Optionally mark all as read with ?markRead=true.
601
+ """
602
+ uid = verify_token(request.headers.get('Authorization'))
603
+ if not uid:
604
+ return jsonify({'error': 'Unauthorized'}), 401
605
+
606
+ try:
607
+ notifs_ref = db_ref.child(f'notifications/{uid}')
608
+ notifs_data = notifs_ref.get() or {}
609
+
610
+ notifications = sorted(
611
+ [{'id': k, **v} for k, v in notifs_data.items()],
612
+ key=lambda x: x.get('createdAt', ''),
613
+ reverse=True,
614
+ )
615
+
616
+ if request.args.get('markRead', '').lower() == 'true':
617
+ for n in notifications:
618
+ if not n.get('read'):
619
+ notifs_ref.child(n['id']).update({'read': True})
620
+ for n in notifications:
621
+ n['read'] = True
622
+
623
+ return jsonify(notifications), 200
624
+ except Exception as e:
625
+ logger.error(f"Error fetching notifications for {uid}: {e}")
626
+ return jsonify({'error': str(e)}), 500
627
+
628
+
629
+ @app.route('/api/user/notifications/<string:notification_id>/read', methods=['PUT'])
630
+ def mark_notification_read(notification_id):
631
+ """Marks a single notification as read."""
632
+ uid = verify_token(request.headers.get('Authorization'))
633
+ if not uid:
634
+ return jsonify({'error': 'Unauthorized'}), 401
635
+ try:
636
+ db_ref.child(f'notifications/{uid}/{notification_id}').update({'read': True})
637
+ return jsonify({'success': True}), 200
638
+ except Exception as e:
639
+ return jsonify({'error': str(e)}), 500
640
+
641
+
642
+ # -----------------------------------------------------------------------------
643
+ # 6. ADMIN ENDPOINTS
644
  # -----------------------------------------------------------------------------
645
 
646
  @app.route('/api/admin/profile', methods=['GET'])
647
  def get_admin_profile():
648
  try:
649
+ admin_uid = verify_admin(request.headers.get('Authorization'))
650
+ all_users = db_ref.child('users').get() or {}
 
 
651
  all_projects = db_ref.child('projects').get() or {}
652
  all_feedback = db_ref.child('feedback').get() or {}
653
  all_credit_requests = db_ref.child('credit_requests').get() or {}
654
 
 
655
  total_users = len(all_users)
656
  admin_count = 0
657
  total_credits_in_system = 0
 
662
  if user_data.get('is_admin', False):
663
  admin_count += 1
664
  total_credits_in_system += user_data.get('credits', 0)
 
 
665
  try:
666
  created_at_str = user_data.get('createdAt')
667
  if created_at_str:
 
668
  user_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
669
  if user_created_at.replace(tzinfo=None) > seven_days_ago:
670
  new_users_last_7_days += 1
671
  except (ValueError, TypeError):
 
672
  pass
673
 
674
+ total_projects = len(all_projects)
675
+ projects_by_status = {"awaiting_approval": 0, "awaiting_selection": 0, "ready": 0, "unknown": 0}
 
 
 
 
 
 
676
  projects_by_category = {}
677
 
678
  for project_data in all_projects.values():
679
+ status = project_data.get('status', 'unknown')
 
680
  projects_by_status[status] = projects_by_status.get(status, 0) + 1
 
 
681
  category = project_data.get('category', 'N/A')
682
  projects_by_category[category] = projects_by_category.get(category, 0) + 1
683
 
684
+ open_feedback_count = sum(1 for fb in all_feedback.values() if fb.get('status') == 'open')
 
685
  pending_requests_count = sum(1 for req in all_credit_requests.values() if req.get('status') == 'pending')
686
 
 
687
  admin_personal_data = all_users.get(admin_uid, {})
688
+ return jsonify({
689
+ 'uid': admin_uid,
690
+ 'email': admin_personal_data.get('email'),
691
+ 'credits': admin_personal_data.get('credits'),
 
692
  'is_admin': True,
693
  'dashboardStats': {
694
  'users': {
695
+ 'total': total_users,
696
+ 'admins': admin_count,
697
+ 'regular': total_users - admin_count,
698
+ 'newLast7Days': new_users_last_7_days,
699
+ 'totalCreditsInSystem': total_credits_in_system,
700
  },
701
  'projects': {
702
+ 'total': total_projects,
703
+ 'byStatus': projects_by_status,
704
+ 'byCategory': projects_by_category,
705
  },
706
  'system': {
707
+ 'openFeedback': open_feedback_count,
708
+ 'pendingCreditRequests': pending_requests_count,
709
  }
710
  }
711
+ }), 200
 
 
 
712
  except PermissionError as e:
713
+ return jsonify({'error': str(e)}), 403
714
  except Exception as e:
715
  print(traceback.format_exc())
716
  return jsonify({'error': f"An internal error occurred: {e}"}), 500
717
 
718
+
719
  @app.route('/api/admin/credit_requests', methods=['GET'])
720
  def list_credit_requests():
721
  try:
 
729
  def process_credit_request(request_id):
730
  try:
731
  admin_uid = verify_admin(request.headers.get('Authorization'))
732
+ req_ref = db_ref.child(f'credit_requests/{request_id}')
733
+ req_data = req_ref.get()
734
+ if not req_data:
735
+ return jsonify({'error': 'Credit request not found'}), 404
736
  decision = request.json.get('decision')
737
+ if decision not in ['approved', 'declined']:
738
+ return jsonify({'error': 'Decision must be "approved" or "declined"'}), 400
739
  if decision == 'approved':
740
+ user_ref = db_ref.child(f'users/{req_data["userId"]}')
741
  user_data = user_ref.get()
742
  if user_data:
743
  new_total = user_data.get('credits', 0) + int(req_data.get('requested_credits', 0))
744
  user_ref.update({'credits': new_total})
 
745
  req_ref.update({'status': decision, 'processedBy': admin_uid, 'processedAt': datetime.utcnow().isoformat()})
746
  return jsonify({'success': True, 'message': f'Request {decision}.'})
747
  except Exception as e:
 
761
  try:
762
  verify_admin(request.headers.get('Authorization'))
763
  all_users = db_ref.child('users').get() or {}
764
+ return jsonify([{'uid': uid, **data} for uid, data in all_users.items()])
 
765
  except Exception as e:
766
  return jsonify({'error': str(e)}), 500
767
 
 
770
  try:
771
  verify_admin(request.headers.get('Authorization'))
772
  add_credits = request.json.get('add_credits')
773
+ if add_credits is None:
774
+ return jsonify({'error': 'add_credits is required'}), 400
775
+ user_ref = db_ref.child(f'users/{uid}')
776
  user_data = user_ref.get()
777
+ if not user_data:
778
+ return jsonify({'error': 'User not found'}), 404
779
  new_total = user_data.get('credits', 0) + float(add_credits)
780
  user_ref.update({'credits': new_total})
781
  return jsonify({'success': True, 'new_total_credits': new_total})
782
  except Exception as e:
783
  return jsonify({'error': str(e)}), 500
784
 
785
+
786
+ # ---------------------------------------------------------------------------
787
+ # ADMIN NOTIFICATIONS (in-app + optional email via Resend)
788
+ # ---------------------------------------------------------------------------
789
+
790
+ def _resolve_target_uids(target: str, emails: list | None) -> tuple[list, list]:
791
+ """
792
+ Resolves the admin's targeting intent to a list of (uid, email) pairs.
793
+
794
+ target values:
795
+ 'all' → every user in the database
796
+ 'email' → specific users matched by their email address(es)
797
+
798
+ Returns:
799
+ (uid_list, email_list) – parallel lists
800
+ """
801
+ all_users = db_ref.child('users').get() or {}
802
+
803
+ if target == 'all':
804
+ uids = list(all_users.keys())
805
+ mails = [v.get('email', '') for v in all_users.values()]
806
+ return uids, mails
807
+
808
+ if target == 'email' and emails:
809
+ email_set = {e.strip().lower() for e in emails if e}
810
+ uids, mails = [], []
811
+ for uid, data in all_users.items():
812
+ if (data.get('email') or '').lower() in email_set:
813
+ uids.append(uid)
814
+ mails.append(data.get('email', ''))
815
+ return uids, mails
816
+
817
+ return [], []
818
+
819
+
820
+ @app.route('/api/admin/notifications/send', methods=['POST'])
821
+ def admin_send_notification():
822
+ """
823
+ Sends an in-app notification and optionally an email to one, several,
824
+ or all users.
825
+
826
+ Request body:
827
+ {
828
+ "title": "Scheduled maintenance", // required
829
+ "message": "We will be down at 2 AM.", // required
830
+ "target": "all" | "email", // required
831
+ "emails": ["user@example.com", ...], // required when target=email
832
+ "sendEmail": true, // optional, default false
833
+ "emailSubject": "..." // optional override
834
+ }
835
+
836
+ Response:
837
+ {
838
+ "success": true,
839
+ "notifiedCount": 42,
840
+ "emailResult": { ... } | null
841
+ }
842
+ """
843
+ try:
844
+ admin_uid = verify_admin(request.headers.get('Authorization'))
845
+ except PermissionError as e:
846
+ return jsonify({'error': str(e)}), 403
847
+
848
+ data = request.get_json() or {}
849
+
850
+ title = (data.get('title') or '').strip()
851
+ message = (data.get('message') or '').strip()
852
+ target = (data.get('target') or '').strip().lower()
853
+
854
+ if not title or not message:
855
+ return jsonify({'error': '"title" and "message" are required.'}), 400
856
+
857
+ if target not in ('all', 'email'):
858
+ return jsonify({'error': '"target" must be "all" or "email".'}), 400
859
+
860
+ emails_input = data.get('emails') or []
861
+ if target == 'email' and not emails_input:
862
+ return jsonify({'error': '"emails" list is required when target is "email".'}), 400
863
+
864
+ target_uids, target_emails = _resolve_target_uids(target, emails_input)
865
+
866
+ if not target_uids:
867
+ return jsonify({
868
+ 'success': False,
869
+ 'notifiedCount': 0,
870
+ 'error': 'No users matched the given target / email list.',
871
+ }), 404
872
+
873
+ # --- Write in-app notifications to Firebase ---
874
+ now = datetime.utcnow().isoformat()
875
+ admin_ref = db_ref.child('notifications')
876
+ notification_payload = {
877
+ 'title': title,
878
+ 'message': message,
879
+ 'type': 'admin',
880
+ 'read': False,
881
+ 'createdAt': now,
882
+ 'sentBy': admin_uid,
883
+ }
884
+
885
+ for uid in target_uids:
886
+ user_notif_ref = admin_ref.child(uid).push()
887
+ user_notif_ref.set({**notification_payload, 'id': user_notif_ref.key})
888
+
889
+ logger.info(f"[ADMIN NOTIFY] In-app notifications sent to {len(target_uids)} users by admin {admin_uid}.")
890
+
891
+ # --- Optionally send email via Resend ---
892
+ email_result = None
893
+ if data.get('sendEmail', False):
894
+ subject = (data.get('emailSubject') or title).strip()
895
+ html_body = _notification_email_html(title, message)
896
+
897
+ # Filter out blank addresses
898
+ valid_emails = [e for e in target_emails if e and '@' in e]
899
+
900
+ if valid_emails:
901
+ email_result = send_email_via_resend(valid_emails, subject, html_body)
902
+ logger.info(f"[ADMIN NOTIFY] Email sent to {len(valid_emails)} addresses. Result: {email_result}")
903
+ else:
904
+ email_result = {"success": False, "error": "No valid email addresses found for the selected users."}
905
+
906
+ return jsonify({
907
+ 'success': True,
908
+ 'notifiedCount': len(target_uids),
909
+ 'emailResult': email_result,
910
+ }), 200
911
+
912
+
913
+ @app.route('/api/admin/notifications/history', methods=['GET'])
914
+ def admin_notification_history():
915
+ """
916
+ Returns all notifications ever sent, across all users.
917
+ Useful for the admin dashboard's sent-history panel.
918
+ Ordered by createdAt descending.
919
+ """
920
+ try:
921
+ verify_admin(request.headers.get('Authorization'))
922
+ except PermissionError as e:
923
+ return jsonify({'error': str(e)}), 403
924
+
925
+ try:
926
+ all_notifs_by_user = db_ref.child('notifications').get() or {}
927
+ history = []
928
+ for uid, user_notifs in all_notifs_by_user.items():
929
+ if not isinstance(user_notifs, dict):
930
+ continue
931
+ for nid, notif in user_notifs.items():
932
+ history.append({'notifId': nid, 'userId': uid, **notif})
933
+
934
+ history.sort(key=lambda x: x.get('createdAt', ''), reverse=True)
935
+ return jsonify(history), 200
936
+ except Exception as e:
937
+ logger.error(f"[ADMIN NOTIFY HISTORY] Error: {e}")
938
+ return jsonify({'error': str(e)}), 500
939
+
940
+
941
+ # ---------------------------------------------------------------------------
942
+ # ADMIN – Direct email endpoint (bypasses in-app, pure Resend)
943
+ # ---------------------------------------------------------------------------
944
+
945
+ @app.route('/api/admin/email/send', methods=['POST'])
946
+ def admin_send_email():
947
+ """
948
+ Sends a raw email (no in-app notification) to one, several, or all users.
949
+
950
+ Request body:
951
+ {
952
+ "subject": "Important update",
953
+ "message": "Hello …", // plain text; will be wrapped in HTML template
954
+ "target": "all" | "email",
955
+ "emails": ["a@b.com"] // when target=email
956
+ }
957
+ """
958
+ try:
959
+ verify_admin(request.headers.get('Authorization'))
960
+ except PermissionError as e:
961
+ return jsonify({'error': str(e)}), 403
962
+
963
+ data = request.get_json() or {}
964
+ subject = (data.get('subject') or '').strip()
965
+ message = (data.get('message') or '').strip()
966
+ target = (data.get('target') or '').strip().lower()
967
+
968
+ if not subject or not message:
969
+ return jsonify({'error': '"subject" and "message" are required.'}), 400
970
+ if target not in ('all', 'email'):
971
+ return jsonify({'error': '"target" must be "all" or "email".'}), 400
972
+
973
+ emails_input = data.get('emails') or []
974
+ if target == 'email' and not emails_input:
975
+ return jsonify({'error': '"emails" list is required when target is "email".'}), 400
976
+
977
+ _, target_emails = _resolve_target_uids(target, emails_input)
978
+ valid_emails = [e for e in target_emails if e and '@' in e]
979
+
980
+ if not valid_emails:
981
+ return jsonify({'error': 'No valid email addresses found.'}), 404
982
+
983
+ html_body = _notification_email_html(subject, message)
984
+ email_result = send_email_via_resend(valid_emails, subject, html_body)
985
+
986
+ return jsonify({
987
+ 'success': email_result.get('success'),
988
+ 'sentTo': len(valid_emails),
989
+ 'emailResult': email_result,
990
+ }), 200 if email_result.get('success') else 502
991
+
992
+
993
  # -----------------------------------------------------------------------------
994
+ # 7. DIY PROJECT ENDPOINTS (Core Logic)
995
  # -----------------------------------------------------------------------------
996
+
997
  @app.route('/api/projects', methods=['POST'])
998
  def create_project():
999
  uid = verify_token(request.headers.get('Authorization'))
1000
+ if not uid:
1001
+ return jsonify({'error': 'Unauthorized'}), 401
1002
 
1003
+ user_ref = db_ref.child(f'users/{uid}')
1004
  user_data = user_ref.get()
1005
  if not user_data or user_data.get('credits', 0) < 1:
1006
  return jsonify({'error': 'Insufficient credits'}), 402
 
1008
  if 'image' not in request.files:
1009
  return jsonify({'error': 'Image file is required'}), 400
1010
 
1011
+ image_file = request.files['image']
1012
  context_text = request.form.get('contextText', '')
1013
+ image_bytes = image_file.read()
1014
+ pil_image = Image.open(io.BytesIO(image_bytes))
1015
 
1016
  try:
1017
  category_prompt = (
 
1023
  "Reply with ONLY the category name."
1024
  )
1025
  category = send_text_request(CATEGORY_MODEL, category_prompt, pil_image)
1026
+ if not category:
1027
+ return jsonify({'error': 'Failed to get project category from AI.'}), 500
1028
 
1029
  plan_prompt = f"""
1030
  You are an expert DIY assistant in the category: {category}.
 
1042
  [Your plan or 3 options]
1043
  """
1044
  plan_response = send_text_request(GENERATION_MODEL, plan_prompt, pil_image)
1045
+ if not plan_response:
1046
+ return jsonify({'error': 'Failed to generate project plan from AI.'}), 500
1047
 
1048
+ title = re.search(r"TITLE:\s*(.*)", plan_response).group(1).strip()
1049
+ description = re.search(r"DESCRIPTION:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
1050
  initial_plan_text = re.search(r"INITIAL PLAN:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
1051
 
1052
+ upcycling_options = (
1053
+ re.findall(r"^\s*\d+\.\s*(.*)", initial_plan_text, re.MULTILINE)
1054
+ if "UPCYCLING OPTIONS:" in initial_plan_text
1055
+ else []
1056
+ )
1057
  initial_plan = initial_plan_text if not upcycling_options else ""
1058
+ status = "awaiting_selection" if upcycling_options else "awaiting_approval"
1059
 
1060
  project_id = str(uuid.uuid4())
1061
  image_path = f"users/{uid}/projects/{project_id}/initial_image.png"
1062
+ image_url = upload_to_storage(image_bytes, image_path, content_type=image_file.content_type)
1063
 
1064
  project_data = {
1065
+ "uid": uid, "projectId": project_id, "status": status,
1066
+ "createdAt": datetime.utcnow().isoformat(),
1067
+ "userImageURL": image_url, "contextText": context_text,
1068
+ "projectTitle": title, "projectDescription": description,
1069
+ "category": category, "initialPlan": initial_plan,
1070
  "upcyclingOptions": upcycling_options, "toolsList": [], "steps": []
1071
  }
1072
  db_ref.child(f'projects/{project_id}').set(project_data)
 
1073
  user_ref.update({'credits': user_data.get('credits', 0) - 1})
1074
  return jsonify(project_data), 201
1075
 
 
1082
  def approve_project_plan(project_id):
1083
  start_time = time.time()
1084
  logger.info(f"[PROJECT APPROVAL] Starting approval process for project: {project_id}")
1085
+
 
 
1086
  uid = verify_token(request.headers.get('Authorization'))
1087
  if not uid:
 
1088
  return jsonify({'error': 'Unauthorized'}), 401
 
 
1089
 
1090
+ user_ref = db_ref.child(f'users/{uid}')
 
 
1091
  user_data = user_ref.get()
1092
  if not user_data or user_data.get('credits', 0) < 5:
 
1093
  return jsonify({'error': 'Insufficient credits'}), 402
 
 
1094
 
1095
+ project_ref = db_ref.child(f'projects/{project_id}')
 
 
1096
  project_data = project_ref.get()
1097
  if not project_data or project_data.get('uid') != uid:
 
1098
  return jsonify({'error': 'Project not found or access denied'}), 404
 
 
1099
 
 
1100
  selected_option = request.json.get('selectedOption')
1101
+
 
 
1102
  try:
1103
  response = requests.get(project_data['userImageURL'], timeout=30)
1104
  response.raise_for_status()
 
 
 
 
 
 
 
 
 
1105
  pil_image = Image.open(io.BytesIO(response.content)).convert('RGB')
1106
  except Exception as e:
1107
+ logger.error(f"[PROJECT APPROVAL] Image download/processing failed: {e}")
1108
  return jsonify({'error': 'Failed to process project image'}), 500
 
 
 
1109
 
 
 
1110
  context = (
1111
  f"The user chose the upcycling project: '{selected_option}'."
1112
  if selected_option
1113
  else f"The user has approved the plan for '{project_data['projectTitle']}'."
1114
  )
1115
 
1116
+ # ------------------------------------------------------------------
1117
+ # Expanded system prompt for 2026 – covers fashion, electronics, etc.
1118
+ # ------------------------------------------------------------------
1119
  detailed_prompt = f"""
1120
+ You are Alfred, a world-class DIY and lifestyle expert. The user wants to proceed with the
1121
+ project titled "{project_data['projectTitle']}" in the category "{project_data.get('category', 'DIY')}".
1122
  {context}
1123
+
1124
+ Provide a detailed, practical guide. For EVERY step you MUST provide a clear illustrative image
1125
+ that shows the action being performed.
1126
+
1127
+ Category-specific guidance:
1128
+ - Fashion & Clothing Repair: specify stitch type, thread colour, needle gauge, and fabric handling tips.
1129
+ - Electronics & PCB Repair: include ESD safety warnings, soldering temperatures, and component reference numbers.
1130
+ - Beauty & Personal Care DIY: note skin-type suitability and patch-test reminders.
1131
+ - Furniture Restoration: mention wood grain direction, sanding grits, and cure times.
1132
+ - All categories: prioritise safety, sustainability, and locally available materials.
1133
+
1134
  Format your response EXACTLY like this:
1135
  TOOLS AND MATERIALS:
1136
+ - Tool or Material A
1137
+ - Tool or Material B
1138
+ STEPS (Maximum 5 steps):
1139
  1. First step instructions.
1140
+ 2. Second step instructions.
1141
  """
 
 
1142
 
1143
  try:
1144
+ chat = client.chats.create(
 
 
 
 
1145
  model=GENERATION_MODEL,
1146
  config=types.GenerateContentConfig(response_modalities=["Text", "Image"])
1147
  )
1148
  full_resp = chat.send_message([detailed_prompt, pil_image])
1149
+ ai_time = time.time() - start_time
1150
  logger.info(f"[PROJECT APPROVAL] AI generation completed in {ai_time:.3f}s")
 
 
 
 
1151
 
1152
+ gen_parts = full_resp.candidates[0].content.parts
1153
  combined_text = ""
1154
  inline_images = []
1155
  for part in gen_parts:
 
1160
  inline_images.append(img)
1161
 
1162
  combined_text = combined_text.strip()
 
 
 
 
 
1163
 
1164
+ tools_match = re.search(
1165
+ r"TOOLS AND MATERIALS:\s*(.*?)\s*(?=STEPS\s*[\(\:]|$)",
1166
+ combined_text, re.DOTALL | re.IGNORECASE
1167
+ )
1168
+ steps_match = re.search(
1169
+ r"STEPS\s*\([^)]*\):\s*(.*)|STEPS\s*:\s*(.*)",
1170
+ combined_text, re.DOTALL | re.IGNORECASE
1171
+ )
 
 
 
 
 
 
 
1172
 
1173
+ if not tools_match or not steps_match:
1174
+ logger.error(f"[PROJECT APPROVAL] Parse error. Response: {combined_text[:500]}")
1175
+ return jsonify({'error': 'AI response format error – could not parse sections'}), 500
 
1176
 
1177
  tools_section = tools_match.group(1).strip()
1178
+ steps_section = (steps_match.group(1) or steps_match.group(2)).strip()
 
 
 
 
 
1179
 
1180
+ tools_list = []
1181
+ seen = set()
1182
+ for line in tools_section.split('\n'):
1183
+ item = line.strip("- ").strip()
1184
+ if item and item not in seen:
1185
+ seen.add(item)
1186
+ tools_list.append(item)
1187
 
 
 
 
 
 
 
 
 
 
1188
  parsed_steps = parse_numbered_steps(steps_section)
1189
 
1190
+ if not tools_list or not parsed_steps:
1191
+ return jsonify({'error': 'AI response format error – empty tools or steps'}), 500
 
 
 
 
 
 
 
 
 
 
 
1192
 
1193
  if len(parsed_steps) != len(inline_images):
1194
+ min_len = min(len(parsed_steps), len(inline_images))
1195
+ parsed_steps = parsed_steps[:min_len]
1196
+ inline_images = inline_images[:min_len]
1197
+ if min_len == 0:
1198
+ return jsonify({'error': 'AI response mismatch no valid steps found'}), 500
1199
+
 
 
 
 
 
 
1200
  final_steps = []
 
 
 
1201
  for i, step_info in enumerate(parsed_steps):
 
 
1202
  try:
 
 
1203
  img_byte_arr = io.BytesIO()
1204
  inline_images[i].save(img_byte_arr, format='JPEG', optimize=True, quality=70)
1205
  img_path = f"users/{uid}/projects/{project_id}/steps/step_{i+1}_image.jpg"
1206
+ img_url = upload_to_storage(img_byte_arr.getvalue(), img_path, 'image/jpeg')
 
 
 
 
 
 
1207
  narration_url = generate_tts_audio_and_upload(step_info['text'], uid, project_id, i + 1)
1208
+ step_info.update({"imageUrl": img_url, "narrationUrl": narration_url, "isDone": False, "notes": ""})
 
 
 
 
 
 
 
 
 
 
 
1209
  except Exception as e:
1210
+ logger.error(f"[PROJECT APPROVAL] Error processing step {i+1}: {e}")
1211
+ step_info.update({"imageUrl": "", "narrationUrl": "", "isDone": False, "notes": ""})
1212
+ final_steps.append(step_info)
1213
+
 
 
 
 
 
 
 
 
 
 
 
 
1214
  update_data = {
1215
+ "status": "ready",
1216
+ "toolsList": tools_list,
1217
+ "steps": final_steps,
1218
  "selectedOption": selected_option or ""
1219
  }
1220
  project_ref.update(update_data)
1221
+ user_ref.update({'credits': user_data.get('credits', 0) - 5})
 
 
1222
 
 
 
1223
  updated_project = project_ref.get()
1224
  updated_project["projectId"] = project_id
 
 
 
 
 
 
 
 
1225
 
 
1226
  total_time = time.time() - start_time
1227
+ logger.info(f"[PROJECT APPROVAL] SUCCESS in {total_time:.3f}s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1228
  return jsonify(updated_project)
1229
 
1230
  except Exception as e:
1231
+ logger.error(f"[PROJECT APPROVAL] Exception: {traceback.format_exc()}")
 
 
 
 
 
1232
  return jsonify({'error': f'Internal server error: {str(e)}'}), 500
1233
 
1234
+
1235
  @app.route('/api/projects', methods=['GET'])
1236
  def list_projects():
1237
  uid = verify_token(request.headers.get('Authorization'))
1238
+ if not uid:
1239
+ return jsonify({'error': 'Unauthorized'}), 401
1240
  projects = (db_ref.child('projects').order_by_child('uid').equal_to(uid).get() or {}).values()
1241
  return jsonify(list(projects))
1242
 
1243
  @app.route('/api/projects/<string:project_id>', methods=['GET'])
1244
  def get_project(project_id):
1245
  uid = verify_token(request.headers.get('Authorization'))
1246
+ if not uid:
1247
+ return jsonify({'error': 'Unauthorized'}), 401
1248
  project_data = db_ref.child(f'projects/{project_id}').get()
1249
  if not project_data or project_data.get('uid') != uid:
1250
  return jsonify({'error': 'Project not found or access denied'}), 404
 
1253
  @app.route('/api/projects/<string:project_id>/step/<int:step_number>', methods=['PUT'])
1254
  def update_step(project_id, step_number):
1255
  uid = verify_token(request.headers.get('Authorization'))
1256
+ if not uid:
1257
+ return jsonify({'error': 'Unauthorized'}), 401
1258
  data = request.get_json()
1259
+ if data is None:
1260
+ return jsonify({'error': 'JSON body is required'}), 400
1261
  project_data = db_ref.child(f'projects/{project_id}').get()
1262
  if not project_data or project_data.get('uid') != uid:
1263
  return jsonify({'error': 'Project not found or access denied'}), 404
1264
+ steps = project_data.get('steps', [])
 
1265
  step_index = next((i for i, s in enumerate(steps) if s.get('stepNumber') == step_number), -1)
1266
+ if step_index == -1:
1267
+ return jsonify({'error': f'Step number {step_number} not found'}), 404
1268
  step_path = f'projects/{project_id}/steps/{step_index}'
1269
+ if 'isDone' in data:
1270
+ db_ref.child(f'{step_path}/isDone').set(bool(data['isDone']))
1271
+ if 'notes' in data:
1272
+ db_ref.child(f'{step_path}/notes').set(str(data['notes']))
1273
  return jsonify({"success": True, "updatedStep": db_ref.child(step_path).get()})
1274
 
1275
  @app.route('/api/projects/<string:project_id>', methods=['DELETE'])
1276
  def delete_project(project_id):
1277
  uid = verify_token(request.headers.get('Authorization'))
1278
+ if not uid:
1279
+ return jsonify({'error': 'Unauthorized'}), 401
1280
+ project_ref = db_ref.child(f'projects/{project_id}')
1281
  project_data = project_ref.get()
1282
  if not project_data or project_data.get('uid') != uid:
1283
  return jsonify({'error': 'Project not found or access denied'}), 404
 
1284
  project_ref.delete()
1285
  for blob in bucket.list_blobs(prefix=f"users/{uid}/projects/{project_id}/"):
1286
  blob.delete()
1287
  return jsonify({"success": True, "message": f"Project {project_id} deleted."})
1288
 
1289
 
1290
+ # -----------------------------------------------------------------------------
1291
+ # 8. AI PHONE CALL ElevenLabs + Memory Summary
1292
+ # -----------------------------------------------------------------------------
 
 
 
 
1293
 
1294
  def summarize_user_history(uid):
1295
+ """Fetches past transcripts and returns a concise pre-call briefing via Gemini."""
 
 
 
1296
  try:
1297
+ all_transcripts = db_ref.child(f'transcripts/{uid}').get()
 
 
 
1298
  if not all_transcripts:
 
1299
  return "This is a new user."
1300
 
1301
+ history_list = [
1302
+ {
1303
+ "date": v.get("createdAt"),
1304
+ "project_title": v.get("projectId"),
1305
+ "transcript": v.get("transcript")
1306
+ }
1307
+ for v in all_transcripts.values()
1308
+ ]
1309
  history_json = json.dumps(history_list, indent=2)
1310
 
1311
  analyst_prompt = """
1312
+ You are a world-class executive assistant. Analyse the user's conversation history with
1313
+ Alfred, a DIY expert AI. Produce a concise 'Pre-Call Briefing' using bullet points.
1314
+ Focus on: personal context (who projects are for, life details), preferences,
1315
+ skill level, known tool inventory, recurring questions/struggles, and a brief
1316
+ overall summary. Start the response with 'Here is your briefing on this user:'.
1317
+ If a detail is absent, omit that bullet. Output bullets only, no prose preamble.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1318
  """
1319
 
 
 
 
1320
  response = client.models.generate_content(
1321
+ model=SUMMARY_MODEL,
1322
  contents=[analyst_prompt, history_json]
1323
  )
1324
+ return response.text.strip()
 
 
 
 
1325
  except Exception as e:
1326
+ logger.error(f"[BRIEFING] Failed to generate 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
  uid = verify_token(request.headers.get('Authorization'))
1333
  if not uid:
1334
  return jsonify({'error': 'Unauthorized'}), 401
 
1335
  try:
1336
  memory_summary = summarize_user_history(uid)
 
1337
  return jsonify({"memory_summary": memory_summary}), 200
 
1338
  except Exception as e:
1339
+ logger.error(f"Error in get_call_briefing for user {uid}: {e}")
1340
  return jsonify({'error': 'Failed to generate call briefing.'}), 500
1341
 
1342
+
1343
  @app.route('/api/debug/test-agent', methods=['GET'])
1344
  def test_agent():
 
 
 
1345
  if not ELEVENLABS_API_KEY:
1346
  return jsonify({'error': 'API key not set on server'}), 500
 
1347
  headers = {"xi-api-key": ELEVENLABS_API_KEY}
1348
  results = {'agent_id': AGENT_ID, 'tests': {}}
 
1349
  try:
1350
+ agent_resp = requests.get(
1351
+ f"https://api.elevenlabs.io/v1/convai/agents/{AGENT_ID}",
1352
+ headers=headers, timeout=10
1353
+ )
1354
+ results['tests']['agent_check'] = {'status': agent_resp.status_code, 'exists': agent_resp.ok}
1355
+ conv_resp = requests.get(
1356
+ f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id={AGENT_ID}",
1357
+ headers=headers, timeout=10
1358
+ )
 
 
1359
  results['tests']['get_signed_url_check'] = {
1360
+ 'status': conv_resp.status_code,
1361
  'url_received': 'signed_url' in conv_resp.json() if conv_resp.ok else False
1362
  }
 
1363
  return jsonify(results)
 
1364
  except Exception as e:
1365
  return jsonify({'error': str(e), 'agent_id': AGENT_ID})
1366
 
1367
+
1368
  @app.route('/api/projects/<project_id>/log-call-usage', methods=['POST'])
1369
  def log_call_usage(project_id):
 
 
 
 
 
 
1370
  uid = verify_token(request.headers.get('Authorization'))
1371
  if not uid:
1372
  return jsonify({'error': 'Unauthorized'}), 401
1373
 
1374
+ data = request.get_json()
1375
  duration_seconds = data.get("durationSeconds")
1376
+ transcript = data.get("transcript")
1377
 
1378
  if duration_seconds is None:
1379
  return jsonify({'error': 'Invalid duration provided.'}), 400
1380
 
 
1381
  minutes = math.ceil(duration_seconds / 60)
1382
+ cost = minutes * 3
1383
+ logger.info(f"[LOGGING] Call duration: {duration_seconds:.2f}s, Cost: {cost} credits for user {uid}.")
1384
 
1385
  try:
 
1386
  if transcript and isinstance(transcript, str) and len(transcript) > 10:
1387
+ transcript_id = f"{project_id}_{int(time.time())}"
1388
  transcript_ref = db_ref.child(f'transcripts/{uid}/{transcript_id}')
1389
+ transcript_ref.set({
1390
+ "transcript": transcript,
1391
+ "projectId": project_id,
1392
+ "userId": uid,
1393
  "durationSeconds": duration_seconds,
1394
+ "createdAt": datetime.utcnow().isoformat()
1395
+ })
1396
+ logger.info(f"[LOGGING] Stored transcript {transcript_id} for user {uid}.")
 
 
 
 
 
 
 
1397
 
1398
+ user_ref = db_ref.child(f'users/{uid}')
1399
+ user_data = user_ref.get()
1400
  if user_data is None:
1401
  return jsonify({'error': 'User not found.'}), 404
1402
 
1403
  current_credits = user_data.get('credits', 0)
1404
+ new_credits = max(0, current_credits - cost)
1405
  user_ref.update({'credits': new_credits})
1406
+
 
1407
  return jsonify({
1408
+ "status": "success",
1409
+ "creditsDeducted": cost,
1410
  "remainingCredits": new_credits
1411
  }), 200
 
1412
  except Exception as e:
1413
+ logger.error(f"[LOGGING] DB error for user {uid}: {e}")
1414
  return jsonify({'error': 'A server error occurred while updating credits.'}), 500
1415
 
1416
+
1417
+ # -----------------------------------------------------------------------------
1418
+ # 9. STRIPE BILLING
1419
+ # -----------------------------------------------------------------------------
1420
+
1421
  @app.route("/api/billing/config", methods=["GET"])
1422
  def get_stripe_config():
 
 
 
 
1423
  try:
1424
  return jsonify({
1425
  "publishableKey": os.environ.get("STRIPE_PUBLISHABLE_KEY"),
1426
  "priceIds": {
1427
  "fixer": STRIPE_PRICE_FIXER,
1428
+ "pro": STRIPE_PRICE_PRO,
1429
  }
1430
  }), 200
1431
  except Exception as e:
 
1435
 
1436
  @app.route("/api/billing/create-checkout-session", methods=["POST"])
1437
  def create_checkout_session():
 
 
 
 
 
 
 
1438
  uid = verify_token(request.headers.get("Authorization"))
1439
  if not uid:
1440
  return jsonify({"error": "Unauthorized"}), 401
 
1441
  if not STRIPE_SECRET_KEY or not STRIPE_PRICE_FIXER or not STRIPE_PRICE_PRO:
 
1442
  return jsonify({"error": "Stripe is not configured on the server."}), 500
1443
 
1444
+ data = request.get_json() or {}
1445
  plan_key = (data.get("plan") or "").lower().strip()
 
1446
  plan_cfg = PLAN_CONFIG.get(plan_key)
1447
  if not plan_cfg:
1448
  return jsonify({"error": "Invalid plan selected."}), 400
1449
 
1450
  price_id = plan_cfg.get("price_id")
1451
  if not price_id or not price_id.startswith("price_"):
 
1452
  return jsonify({"error": "Billing configuration error – contact support."}), 500
1453
 
1454
  try:
1455
  customer_id = get_or_create_stripe_customer(uid)
 
1456
  session = stripe.checkout.Session.create(
1457
  mode="subscription",
1458
  customer=customer_id,
1459
  payment_method_types=["card"],
1460
+ line_items=[{"price": price_id, "quantity": 1}],
 
 
 
1461
  success_url=STRIPE_SUCCESS_URL + "?session_id={CHECKOUT_SESSION_ID}",
1462
  cancel_url=STRIPE_CANCEL_URL,
1463
+ metadata={"firebase_uid": uid, "plan": plan_key},
1464
+ subscription_data={"metadata": {"firebase_uid": uid, "plan": plan_key}},
 
 
 
 
 
 
 
 
 
 
 
 
1465
  )
1466
+ logger.info(f"[STRIPE] Created checkout session {session.id} for user {uid}, plan {plan_key}")
1467
+ return jsonify({"id": session.id, "url": session.url}), 200
 
 
 
1468
  except Exception as e:
1469
  logger.error(f"[STRIPE] Error creating checkout session: {e}")
1470
  return jsonify({"error": "Failed to create checkout session."}), 500
1471
+
1472
+
1473
  @app.route("/api/billing/webhook", methods=["POST"])
1474
  def stripe_webhook():
 
 
 
 
 
 
 
 
1475
  if not STRIPE_WEBHOOK_SECRET:
 
1476
  return jsonify({"error": "Webhook secret not configured."}), 500
1477
 
1478
+ payload = request.data
1479
  sig_header = request.headers.get("Stripe-Signature")
 
1480
  try:
1481
+ event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
 
 
1482
  except stripe.error.SignatureVerificationError as e:
1483
+ logger.error(f"[STRIPE] Webhook signature failed: {e}")
1484
  return "Invalid signature", 400
1485
  except Exception as e:
1486
  logger.error(f"[STRIPE] Webhook parsing error: {e}")
1487
  return "Bad request", 400
1488
 
1489
  event_type = event.get("type")
1490
+ data_obj = event.get("data", {}).get("object", {})
 
1491
  logger.info(f"[STRIPE] Webhook event: {event_type}")
1492
 
1493
  try:
 
1494
  if event_type == "checkout.session.completed":
1495
  session = data_obj
1496
+ metadata = session.get("metadata") or {}
1497
+ uid = metadata.get("firebase_uid")
1498
+ plan_key = metadata.get("plan")
1499
  subscription_id = session.get("subscription")
 
 
 
 
 
1500
  if subscription_id:
1501
  sub = stripe.Subscription.retrieve(subscription_id)
1502
  update_user_subscription_from_stripe(sub)
 
 
1503
  if uid and plan_key:
1504
  reset_plan_credits(uid, plan_key)
1505
 
 
1506
  elif event_type == "invoice.payment_succeeded":
1507
+ subscription_id = data_obj.get("subscription")
 
 
1508
  if subscription_id:
1509
+ sub = stripe.Subscription.retrieve(subscription_id)
1510
  metadata = sub.get("metadata") or {}
1511
+ uid = metadata.get("firebase_uid")
1512
  plan_key = metadata.get("plan")
 
 
 
 
 
1513
  update_user_subscription_from_stripe(sub)
 
 
1514
  if uid and plan_key:
1515
  reset_plan_credits(uid, plan_key)
1516
 
 
1517
  elif event_type == "invoice.payment_failed":
1518
+ subscription_id = data_obj.get("subscription")
 
 
1519
  if subscription_id:
1520
  sub = stripe.Subscription.retrieve(subscription_id)
 
 
 
 
1521
  update_user_subscription_from_stripe(sub)
1522
 
1523
+ elif event_type in ("customer.subscription.deleted", "customer.subscription.updated"):
1524
+ update_user_subscription_from_stripe(data_obj)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1525
 
1526
  except Exception as e:
1527
  logger.error(f"[STRIPE] Error handling webhook {event_type}: {e}")
1528
 
1529
  return "", 200
1530
+
1531
+
1532
  # -----------------------------------------------------------------------------
1533
+ # 10. MAIN EXECUTION
1534
  # -----------------------------------------------------------------------------
1535
  if __name__ == '__main__':
1536
  app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))