rairo commited on
Commit
98fd5aa
·
verified ·
1 Parent(s): e8ed341

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +326 -216
main.py CHANGED
@@ -4,6 +4,7 @@ import re
4
  import json
5
  import uuid
6
  import time
 
7
  import traceback
8
  from datetime import datetime, timedelta
9
 
@@ -31,6 +32,12 @@ logger = logging.getLogger(__name__)
31
  app = Flask(__name__)
32
  CORS(app)
33
 
 
 
 
 
 
 
34
  # --- Firebase Initialization ---
35
  try:
36
  credentials_json_string = os.environ.get("FIREBASE")
@@ -112,6 +119,8 @@ def upload_to_storage(data_bytes, destination_blob_name, content_type):
112
 
113
  def safe_float(x, default=None):
114
  try:
 
 
115
  return float(x)
116
  except Exception:
117
  return default
@@ -125,6 +134,28 @@ def safe_int(x, default=None):
125
  def normalize_text(s: str) -> str:
126
  return re.sub(r"\s+", " ", str(s or "")).strip().lower()
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def send_text_request(model_name, prompt, image=None):
129
  """
130
  Helper: if image is provided, send [prompt, image].
@@ -183,6 +214,10 @@ def require_role(uid: str, allowed_roles: list[str]) -> dict:
183
  f"User profile missing in RTDB at /users/{uid}. "
184
  f"Call /api/auth/social-signin (or /api/auth/signup) once after login to bootstrap the profile."
185
  )
 
 
 
 
186
 
187
  role = (user_data.get("role") or "").lower().strip()
188
  if role not in allowed_roles:
@@ -194,21 +229,45 @@ def require_role(uid: str, allowed_roles: list[str]) -> dict:
194
  def get_or_create_profile(uid: str) -> dict:
195
  """
196
  Ensures /users/{uid} exists in RTDB for any authenticated user.
197
- - If missing, bootstraps from Firebase Auth and defaults role to 'customer'.
198
  """
199
  ref = db_ref.child(f"users/{uid}")
200
  user_data = ref.get()
 
 
 
 
 
 
 
 
201
  if user_data:
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  return user_data
203
 
204
- fb_user = auth.get_user(uid)
 
 
205
  new_user_data = {
206
- "email": fb_user.email or "",
207
  "displayName": fb_user.display_name or "",
208
  "phone": "",
209
  "city": "",
210
- "role": "customer", # safe default so task posting works out-of-the-box
211
- "is_admin": False,
 
212
  "createdAt": now_iso()
213
  }
214
  ref.set(new_user_data)
@@ -271,7 +330,7 @@ def health():
271
  return jsonify({"ok": True, "service": "oneplus-server", "time": now_iso()}), 200
272
 
273
  # -----------------------------------------------------------------------------
274
- # 4. AUTH & USER PROFILES (MVP)
275
  # -----------------------------------------------------------------------------
276
 
277
  @app.route("/api/auth/signup", methods=["POST"])
@@ -297,13 +356,20 @@ def signup():
297
 
298
  user = auth.create_user(email=email, password=password, display_name=display_name)
299
 
 
 
 
 
 
 
300
  user_data = {
301
  "email": email,
302
  "displayName": display_name,
303
  "phone": phone,
304
  "city": city,
305
  "role": role,
306
- "is_admin": False,
 
307
  "createdAt": now_iso()
308
  }
309
  db_ref.child(f"users/{user.uid}").set(user_data)
@@ -318,43 +384,16 @@ def signup():
318
  @app.route("/api/auth/social-signin", methods=["POST"])
319
  def social_signin():
320
  """
321
- Ensures RTDB user record exists. Social login happens on client,
322
- we just bootstrap profile.
323
  """
324
  uid = verify_token(request.headers.get("Authorization"))
325
  if not uid:
326
  return jsonify({"error": "Invalid or expired token"}), 401
327
 
328
- user_ref = db_ref.child(f"users/{uid}")
329
- user_data = user_ref.get()
330
-
331
  try:
332
- fb_user = auth.get_user(uid)
333
-
334
- # ---- NEW: If user record exists but missing role, default safely
335
- if user_data:
336
- patch = {}
337
- if not user_data.get("displayName") and fb_user.display_name:
338
- patch["displayName"] = fb_user.display_name
339
- if not (user_data.get("role") or "").strip():
340
- patch["role"] = "customer"
341
- if patch:
342
- user_ref.update(patch)
343
- user_data = user_ref.get()
344
- return jsonify({"uid": uid, **(user_data or {})}), 200
345
-
346
- # create profile
347
- new_user_data = {
348
- "email": fb_user.email,
349
- "displayName": fb_user.display_name,
350
- "phone": "",
351
- "city": "",
352
- "role": "customer",
353
- "is_admin": False,
354
- "createdAt": now_iso()
355
- }
356
- user_ref.set(new_user_data)
357
- return jsonify({"success": True, "uid": uid, **new_user_data}), 201
358
 
359
  except Exception as e:
360
  logger.error(f"social_signin failed: {e}")
@@ -363,20 +402,7 @@ def social_signin():
363
  @app.route("/api/auth/set-role", methods=["POST"])
364
  def set_role_after_social_signin():
365
  """
366
- Set role after first social sign-in (or first time profile is missing).
367
-
368
- Allowed transitions:
369
- - missing profile -> bootstrap -> set role
370
- - role missing/empty -> set role (customer|tasker)
371
- - customer -> tasker (one-way upgrade ONCE)
372
- - tasker -> customer (BLOCK)
373
- - same role -> idempotent 200
374
-
375
- Responses:
376
- - 401 invalid token
377
- - 400 invalid role
378
- - 409 blocked role change
379
- - 200 success (profile returned)
380
  """
381
  uid = verify_token(request.headers.get("Authorization"))
382
  if not uid:
@@ -390,21 +416,11 @@ def set_role_after_social_signin():
390
 
391
  try:
392
  user_ref = db_ref.child(f"users/{uid}")
393
- user_data = user_ref.get()
394
-
395
- # Bootstrap if missing
396
- if not user_data:
397
- fb_user = auth.get_user(uid)
398
- user_data = {
399
- "email": fb_user.email or "",
400
- "displayName": fb_user.display_name or "",
401
- "phone": "",
402
- "city": "",
403
- "role": "", # intentionally empty so user must choose
404
- "is_admin": False,
405
- "createdAt": now_iso(),
406
- }
407
- user_ref.set(user_data)
408
 
409
  current_role = (user_data.get("role") or "").lower().strip()
410
 
@@ -426,13 +442,10 @@ def set_role_after_social_signin():
426
 
427
  # One-way upgrade: customer -> tasker (allow ONCE)
428
  if current_role == "customer" and requested_role == "tasker":
429
- # If you want to allow this only once, the existence of roleUpgradedAt is enough
430
  if (user_data.get("roleUpgradedAt") or "").strip():
431
  return jsonify({
432
  "error": "Role change blocked",
433
- "reason": "Customer -> Tasker upgrade already used. Role flipping is not allowed.",
434
- "currentRole": current_role,
435
- "requestedRole": requested_role
436
  }), 409
437
 
438
  patch = {
@@ -444,17 +457,14 @@ def set_role_after_social_signin():
444
  updated = user_ref.get() or {}
445
  return jsonify({"success": True, "uid": uid, "profile": updated, "note": "upgraded customer -> tasker"}), 200
446
 
447
- # Block any other change (tasker->customer or any flip)
448
  return jsonify({
449
  "error": "Role change blocked",
450
  "reason": "Role flipping is not allowed.",
451
- "currentRole": current_role,
452
- "requestedRole": requested_role
453
  }), 409
454
 
455
  except Exception as e:
456
  logger.error(f"[SET ROLE] failed: {e}")
457
- logger.error(traceback.format_exc())
458
  return jsonify({"error": "Internal server error"}), 500
459
 
460
  @app.route("/api/user/profile", methods=["GET"])
@@ -463,7 +473,6 @@ def get_user_profile():
463
  if not uid:
464
  return jsonify({"error": "Invalid or expired token"}), 401
465
 
466
- # ---- NEW: auto-bootstrap profile so profile fetch never mysteriously 404s
467
  try:
468
  user_data = get_or_create_profile(uid)
469
  return jsonify({"uid": uid, **user_data}), 200
@@ -474,15 +483,17 @@ def get_user_profile():
474
 
475
  @app.route("/api/user/profile", methods=["PUT"])
476
  def update_user_profile():
 
 
 
 
477
  uid = verify_token(request.headers.get("Authorization"))
478
  if not uid:
479
  return jsonify({"error": "Invalid or expired token"}), 401
480
 
481
- # ---- NEW: ensure profile exists before update
482
  try:
483
  _ = get_or_create_profile(uid)
484
  except Exception as e:
485
- logger.error(f"update_user_profile bootstrap failed: {e}")
486
  return jsonify({"error": "Failed to bootstrap profile"}), 500
487
 
488
  data = request.get_json() or {}
@@ -493,15 +504,14 @@ def update_user_profile():
493
  if key in data:
494
  allowed[key] = data.get(key)
495
 
496
- # Role-specific (tasker)
497
- for key in ["skills", "categories", "bio", "serviceRadiusKm", "baseRate", "profilePhotoUrl", "availability"]:
498
  if key in data:
499
- allowed[key] = data.get(key)
500
-
501
- # ---- NEW: allow role updates ONLY if explicitly permitted (optional safeguard)
502
- # If you don't want clients to ever change role, leave this out entirely.
503
- # if "role" in data:
504
- # return jsonify({"error": "Role cannot be updated from client"}), 400
505
 
506
  if not allowed:
507
  return jsonify({"error": "No valid fields provided"}), 400
@@ -518,30 +528,19 @@ def update_user_profile():
518
  return jsonify({"error": f"Failed to update profile: {str(e)}"}), 500
519
 
520
  # -----------------------------------------------------------------------------
521
- # 5. AI (CUSTOMER) SMART CAPTURE (MVP Critical)
522
  # -----------------------------------------------------------------------------
523
 
524
  @app.route("/api/ai/smart-capture", methods=["POST"])
525
  def smart_capture():
526
  """
527
- Customer uploads image (or video thumbnail) + optional context text.
528
- Server sends to Gemini Vision and returns structured output for prefill:
529
- - category
530
- - problemSummary
531
- - difficulty
532
- - timeEstimate
533
- - priceBand
534
- - suggestedMaterials[]
535
- - suggestedTitle
536
- - suggestedDescription
537
- - suggestedBudgetRange
538
  """
539
  uid = verify_token(request.headers.get("Authorization"))
540
  if not uid:
541
  return jsonify({"error": "Unauthorized"}), 401
542
 
543
  try:
544
- # Accept multipart like SozoFix
545
  if "image" not in request.files:
546
  return jsonify({"error": "Image file is required (field name: image)"}), 400
547
 
@@ -600,14 +599,12 @@ Rules:
600
 
601
  except Exception as e:
602
  logger.error(f"[SMART CAPTURE] Error: {e}")
603
- logger.error(traceback.format_exc())
604
  return jsonify({"error": "Internal server error"}), 500
605
 
606
  @app.route("/api/ai/improve-description", methods=["POST"])
607
  def improve_description():
608
  """
609
- MVP nice-to-have:
610
- User provides a short/vague description; AI rewrites professionally and adds questions.
611
  """
612
  uid = verify_token(request.headers.get("Authorization"))
613
  if not uid:
@@ -644,38 +641,84 @@ JSON only, no markdown.
644
  return jsonify({"success": True, "result": result}), 200
645
 
646
  # -----------------------------------------------------------------------------
647
- # 6. TASKS (CUSTOMER POSTS, TASKER BROWSES) + MEDIA UPLOAD (MVP)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  # -----------------------------------------------------------------------------
649
 
650
  @app.route("/api/tasks", methods=["POST"])
651
  def create_task():
652
  """
653
  Customer creates a task.
654
- Upload media like SozoFix:
655
- - multipart/form-data
656
- - fields: category, title, description, city, address(optional), budget, scheduleAt(optional ISO), contextText(optional)
657
- - file fields: media (can send multiple) OR image (single)
658
  """
659
  uid = verify_token(request.headers.get("Authorization"))
660
  if not uid:
661
  return jsonify({"error": "Unauthorized"}), 401
662
 
663
  try:
664
- # ---- NEW: Always ensure RTDB profile exists (prevents 403 due to missing /users/{uid})
665
  profile = get_or_create_profile(uid)
666
 
667
- # ---- NEW: role gate with explicit, debuggable response
668
  role = (profile.get("role") or "").lower().strip()
669
  if role not in ["customer", "admin"]:
670
  return jsonify({
671
  "error": "Forbidden",
672
- "reason": f"Role '{role}' not allowed to create tasks. Must be customer (or admin).",
673
- "uid": uid
674
  }), 403
675
 
676
- # ---- NEW: helpful logs for production debugging
677
- logger.info(f"[CREATE TASK] uid={uid} role={role} email={profile.get('email')}")
678
-
679
  # multipart
680
  category = request.form.get("category", "").strip()
681
  title = request.form.get("title", "").strip()
@@ -683,8 +726,12 @@ def create_task():
683
  city = request.form.get("city", "").strip()
684
  address = request.form.get("address", "").strip()
685
  budget = request.form.get("budget", "").strip()
686
- schedule_at = request.form.get("scheduleAt", "").strip() # ISO string from UI
687
- smart_capture_json = request.form.get("smartCapture", "").strip() # optional JSON string from UI
 
 
 
 
688
 
689
  if not category or not city or not description:
690
  return jsonify({"error": "category, city, and description are required"}), 400
@@ -710,7 +757,6 @@ def create_task():
710
  url = upload_to_storage(data_bytes, path, f.mimetype or "application/octet-stream")
711
  media_urls.append(url)
712
 
713
- # optional smartCapture object
714
  smart_capture = None
715
  if smart_capture_json:
716
  try:
@@ -729,9 +775,13 @@ def create_task():
729
  "description": description or (smart_capture or {}).get("suggestedDescription") or "",
730
  "city": city,
731
  "address": address,
 
 
 
 
732
 
733
- "budget": budget, # keep as string to avoid currency assumptions
734
- "scheduleAt": schedule_at, # ISO string
735
  "mediaUrls": media_urls,
736
 
737
  "smartCapture": smart_capture or {},
@@ -745,47 +795,61 @@ def create_task():
745
 
746
  db_ref.child(f"tasks/{task_id}").set(task_payload)
747
 
748
- # Notify taskers (basic broadcast by category + city)
749
  notify_taskers_for_new_task(task_payload)
750
 
751
  return jsonify({"success": True, "task": task_payload}), 201
752
 
753
  except PermissionError as e:
754
- # Keep PermissionError mapping, but now it will be far more informative when it happens
755
  return jsonify({"error": "Forbidden", "reason": str(e)}), 403
756
  except Exception as e:
757
  logger.error(f"[CREATE TASK] Error: {e}")
758
- logger.error(traceback.format_exc())
759
  return jsonify({"error": "Internal server error"}), 500
760
 
761
  def notify_taskers_for_new_task(task: dict):
762
  """
763
- MVP matching:
764
- - loop through users with role=tasker
765
- - match category overlap + city match (or empty city)
766
- - push in-app notification
767
  """
768
  try:
769
  users = db_ref.child("users").get() or {}
770
- tcat = normalize_text(task.get("category"))
771
- tcity = normalize_text(task.get("city"))
 
 
772
 
773
  for tasker_id, u in users.items():
774
  if (u.get("role") or "").lower().strip() != "tasker":
775
  continue
776
-
777
- # City match: if tasker has city set, match it; else allow.
778
- ucity = normalize_text(u.get("city"))
779
- if ucity and tcity and ucity != tcity:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
  continue
781
 
782
- # Category match: if tasker categories list exists, try overlap; else allow.
783
  cats = u.get("categories") or []
784
  if isinstance(cats, str):
785
  cats = [c.strip() for c in cats.split(",") if c.strip()]
786
 
787
  if cats:
788
- ok = any(normalize_text(c) == tcat for c in cats)
789
  if not ok:
790
  continue
791
 
@@ -802,12 +866,7 @@ def notify_taskers_for_new_task(task: dict):
802
  @app.route("/api/tasks", methods=["GET"])
803
  def list_tasks():
804
  """
805
- Role-aware list:
806
- - customer: list my tasks
807
- - tasker: list open/bidding tasks (with filters)
808
- - admin: list all tasks (optional filters)
809
- Query params:
810
- status, category, city, mine=true
811
  """
812
  uid = verify_token(request.headers.get("Authorization"))
813
  if not uid:
@@ -835,7 +894,6 @@ def list_tasks():
835
  continue
836
 
837
  elif role == "tasker":
838
- # default: show open/bidding, or mine jobs if mine=true
839
  if mine:
840
  if t.get("assignedTaskerId") != uid:
841
  continue
@@ -855,7 +913,6 @@ def list_tasks():
855
 
856
  out.append(t)
857
 
858
- # sort newest first
859
  out.sort(key=lambda x: x.get("createdAt") or "", reverse=True)
860
  return jsonify(out), 200
861
 
@@ -863,6 +920,69 @@ def list_tasks():
863
  logger.error(f"[LIST TASKS] Error: {e}")
864
  return jsonify({"error": "Internal server error"}), 500
865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
  @app.route("/api/tasks/<string:task_id>", methods=["GET"])
867
  def get_task(task_id):
868
  uid = verify_token(request.headers.get("Authorization"))
@@ -893,7 +1013,7 @@ def get_task(task_id):
893
  @app.route("/api/tasks/<string:task_id>", methods=["PUT"])
894
  def update_task(task_id):
895
  """
896
- Customer can edit task only if not assigned/in_progress/completed.
897
  """
898
  uid = verify_token(request.headers.get("Authorization"))
899
  if not uid:
@@ -914,7 +1034,8 @@ def update_task(task_id):
914
 
915
  data = request.get_json() or {}
916
  allowed = {}
917
- for key in ["category", "title", "description", "city", "address", "budget", "scheduleAt"]:
 
918
  if key in data:
919
  allowed[key] = data.get(key)
920
 
@@ -934,12 +1055,6 @@ def update_task(task_id):
934
 
935
  @app.route("/api/tasks/<string:task_id>/status", methods=["PUT"])
936
  def update_task_status(task_id):
937
- """
938
- Status transitions (MVP):
939
- customer: cancel, mark_completed
940
- tasker: on_the_way, in_progress, mark_completed (if assigned)
941
- admin: any
942
- """
943
  uid = verify_token(request.headers.get("Authorization"))
944
  if not uid:
945
  return jsonify({"error": "Unauthorized"}), 401
@@ -973,13 +1088,11 @@ def update_task_status(task_id):
973
  if task.get("status") in ["completed", "cancelled"]:
974
  return jsonify({"error": "Task already closed"}), 400
975
  task_ref.update({"status": "cancelled", "cancelledAt": now_iso()})
976
- # notify assigned tasker (if any)
977
  if task.get("assignedTaskerId"):
978
  push_notification(task["assignedTaskerId"], "task_cancelled", "Task cancelled", "Customer cancelled the task.", {"taskId": task_id})
979
  return jsonify({"success": True, "task": task_ref.get()}), 200
980
 
981
  if new_status == "completed":
982
- # allow completion only if was assigned/in_progress
983
  if task.get("status") not in ["assigned", "in_progress"]:
984
  return jsonify({"error": "Task not in a completable state"}), 400
985
  task_ref.update({"status": "completed", "completedAt": now_iso()})
@@ -997,10 +1110,7 @@ def update_task_status(task_id):
997
  if new_status not in ["on_the_way", "in_progress", "completed"]:
998
  return jsonify({"error": "Invalid tasker status update"}), 400
999
 
1000
- # map on_the_way as in_progress-ish, but keep it if you want
1001
  task_ref.update({"status": new_status, "updatedAt": now_iso()})
1002
-
1003
- # notify customer
1004
  push_notification(task["createdBy"], "task_update", "Task update", f"Task status: {new_status}", {"taskId": task_id})
1005
  return jsonify({"success": True, "task": task_ref.get()}), 200
1006
 
@@ -1013,8 +1123,7 @@ def update_task_status(task_id):
1013
  @app.route("/api/tasks/<string:task_id>", methods=["DELETE"])
1014
  def delete_task(task_id):
1015
  """
1016
- Customer can delete only if open/bidding and no assignment.
1017
- Also removes task media folder.
1018
  """
1019
  uid = verify_token(request.headers.get("Authorization"))
1020
  if not uid:
@@ -1057,21 +1166,21 @@ def delete_task(task_id):
1057
  return jsonify({"error": "Internal server error"}), 500
1058
 
1059
  # -----------------------------------------------------------------------------
1060
- # 7. BIDDING (TASKERS SUBMIT OFFERS) (MVP)
1061
  # -----------------------------------------------------------------------------
1062
 
1063
  @app.route("/api/tasks/<string:task_id>/bids", methods=["POST"])
1064
  def submit_bid(task_id):
1065
  """
1066
- Tasker submits bid: price + timeline + message.
1067
- Stored under /bids/{taskId}/{bidId}
1068
  """
1069
  uid = verify_token(request.headers.get("Authorization"))
1070
  if not uid:
1071
  return jsonify({"error": "Unauthorized"}), 401
1072
 
1073
  try:
1074
- require_role(uid, ["tasker", "admin"])
1075
 
1076
  task = db_ref.child(f"tasks/{task_id}").get()
1077
  if not task:
@@ -1093,6 +1202,10 @@ def submit_bid(task_id):
1093
  "bidId": bid_id,
1094
  "taskId": task_id,
1095
  "taskerId": uid,
 
 
 
 
1096
  "price": price,
1097
  "timeline": timeline,
1098
  "message": message,
@@ -1101,11 +1214,9 @@ def submit_bid(task_id):
1101
  }
1102
  db_ref.child(f"bids/{task_id}/{bid_id}").set(bid)
1103
 
1104
- # flip task to bidding
1105
  if task.get("status") == "open":
1106
  db_ref.child(f"tasks/{task_id}").update({"status": "bidding", "updatedAt": now_iso()})
1107
 
1108
- # notify customer
1109
  push_notification(
1110
  to_uid=task["createdBy"],
1111
  notif_type="new_bid",
@@ -1124,11 +1235,6 @@ def submit_bid(task_id):
1124
 
1125
  @app.route("/api/tasks/<string:task_id>/bids", methods=["GET"])
1126
  def list_bids(task_id):
1127
- """
1128
- Customer: can see bids for own task
1129
- Tasker: can see bids if they bid
1130
- Admin: all
1131
- """
1132
  uid = verify_token(request.headers.get("Authorization"))
1133
  if not uid:
1134
  return jsonify({"error": "Unauthorized"}), 401
@@ -1155,7 +1261,6 @@ def list_bids(task_id):
1155
  return jsonify(out), 200
1156
 
1157
  if role == "tasker":
1158
- # only show their own bids unless task assigned to them
1159
  if task.get("assignedTaskerId") == uid:
1160
  out.sort(key=lambda x: x.get("createdAt") or "", reverse=True)
1161
  return jsonify(out), 200
@@ -1171,13 +1276,6 @@ def list_bids(task_id):
1171
 
1172
  @app.route("/api/tasks/<string:task_id>/select-bid", methods=["PUT"])
1173
  def select_bid(task_id):
1174
- """
1175
- Customer selects a bid:
1176
- - set task.assignedTaskerId
1177
- - set task.selectedBidId
1178
- - set task.status = assigned
1179
- - notify tasker + customer
1180
- """
1181
  uid = verify_token(request.headers.get("Authorization"))
1182
  if not uid:
1183
  return jsonify({"error": "Unauthorized"}), 401
@@ -1210,13 +1308,12 @@ def select_bid(task_id):
1210
  "updatedAt": now_iso()
1211
  })
1212
 
1213
- # mark bid as accepted, others as rejected (MVP)
1214
  bids = db_ref.child(f"bids/{task_id}").get() or {}
1215
  for bkey, b in bids.items():
1216
  st = "accepted" if bkey == bid_id else "rejected"
1217
  db_ref.child(f"bids/{task_id}/{bkey}").update({"status": st})
1218
 
1219
- # notify tasker
1220
  push_notification(
1221
  to_uid=tasker_id,
1222
  notif_type="bid_accepted",
@@ -1225,12 +1322,11 @@ def select_bid(task_id):
1225
  meta={"taskId": task_id, "bidId": bid_id}
1226
  )
1227
 
1228
- # notify customer
1229
  push_notification(
1230
  to_uid=task["createdBy"],
1231
  notif_type="task_assigned",
1232
  title="Task assigned",
1233
- body="You assigned the task to a tasker. You can now chat.",
1234
  meta={"taskId": task_id, "assignedTaskerId": tasker_id}
1235
  )
1236
 
@@ -1243,7 +1339,7 @@ def select_bid(task_id):
1243
  return jsonify({"error": "Internal server error"}), 500
1244
 
1245
  # -----------------------------------------------------------------------------
1246
- # 8. CHAT (REAL-TIME DB STORED) (MVP)
1247
  # -----------------------------------------------------------------------------
1248
 
1249
  @app.route("/api/chats/<string:task_id>/messages", methods=["GET"])
@@ -1275,10 +1371,7 @@ def list_messages(task_id):
1275
  @app.route("/api/chats/<string:task_id>/messages", methods=["POST"])
1276
  def send_message(task_id):
1277
  """
1278
- Send message. Optional attachment upload (single file) via multipart:
1279
- - text in form field "text"
1280
- - file in field "file"
1281
- Or JSON body: {"text": "..."} for text-only
1282
  """
1283
  uid = verify_token(request.headers.get("Authorization"))
1284
  if not uid:
@@ -1329,7 +1422,6 @@ def send_message(task_id):
1329
  }
1330
  db_ref.child(f"chats/{task_id}/{msg_id}").set(msg)
1331
 
1332
- # notify the other party
1333
  other_uid = None
1334
  if uid == task.get("createdBy"):
1335
  other_uid = task.get("assignedTaskerId") or None
@@ -1352,7 +1444,7 @@ def send_message(task_id):
1352
  return jsonify({"error": "Internal server error"}), 500
1353
 
1354
  # -----------------------------------------------------------------------------
1355
- # 9. NOTIFICATIONS (IN-APP) (MVP)
1356
  # -----------------------------------------------------------------------------
1357
 
1358
  @app.route("/api/notifications", methods=["GET"])
@@ -1372,6 +1464,7 @@ def list_notifications():
1372
 
1373
  @app.route("/api/notifications/<string:notif_id>/read", methods=["PUT"])
1374
  def mark_notification_read(notif_id):
 
1375
  uid = verify_token(request.headers.get("Authorization"))
1376
  if not uid:
1377
  return jsonify({"error": "Unauthorized"}), 401
@@ -1388,7 +1481,7 @@ def mark_notification_read(notif_id):
1388
  return jsonify({"error": "Internal server error"}), 500
1389
 
1390
  # -----------------------------------------------------------------------------
1391
- # 10. REVIEWS (CUSTOMER RATES TASKER AFTER COMPLETION) (MVP)
1392
  # -----------------------------------------------------------------------------
1393
 
1394
  @app.route("/api/tasks/<string:task_id>/review", methods=["POST"])
@@ -1441,7 +1534,7 @@ def leave_review(task_id):
1441
  return jsonify({"error": "Internal server error"}), 500
1442
 
1443
  # -----------------------------------------------------------------------------
1444
- # 11. ADMIN (MVP OVERVIEW + MANAGEMENT)
1445
  # -----------------------------------------------------------------------------
1446
 
1447
  @app.route("/api/admin/overview", methods=["GET"])
@@ -1451,38 +1544,31 @@ def admin_overview():
1451
 
1452
  users = db_ref.child("users").get() or {}
1453
  tasks = db_ref.child("tasks").get() or {}
1454
- # lightweight counts only
1455
  total_users = len(users)
1456
  total_taskers = sum(1 for u in users.values() if (u.get("role") or "").lower().strip() == "tasker")
1457
  total_customers = sum(1 for u in users.values() if (u.get("role") or "").lower().strip() == "customer")
 
 
 
1458
 
1459
  by_status = {}
1460
  for t in tasks.values():
1461
  s = t.get("status") or "unknown"
1462
  by_status[s] = by_status.get(s, 0) + 1
1463
 
1464
- # bids count (shallow)
1465
- bids_root = db_ref.child("bids").get() or {}
1466
- total_bids = 0
1467
- for task_bids in bids_root.values():
1468
- if isinstance(task_bids, dict):
1469
- total_bids += len(task_bids)
1470
-
1471
  return jsonify({
1472
  "uid": admin_uid,
1473
- "dashboardStats": {
1474
- "users": {
1475
- "total": total_users,
1476
- "customers": total_customers,
1477
- "taskers": total_taskers
1478
- },
1479
- "tasks": {
1480
- "total": len(tasks),
1481
- "byStatus": by_status
1482
- },
1483
- "bids": {
1484
- "total": total_bids
1485
- }
1486
  }
1487
  }), 200
1488
 
@@ -1522,11 +1608,7 @@ def admin_list_tasks():
1522
 
1523
  @app.route("/api/admin/users/<string:uid>/deactivate", methods=["PUT"])
1524
  def admin_deactivate_user(uid):
1525
- """
1526
- MVP deactivate:
1527
- - sets users/{uid}/disabled=true
1528
- (Client should enforce; Auth disable is optional for later)
1529
- """
1530
  try:
1531
  verify_admin(request.headers.get("Authorization"))
1532
  ref = db_ref.child(f"users/{uid}")
@@ -1543,6 +1625,7 @@ def admin_deactivate_user(uid):
1543
 
1544
  @app.route("/api/admin/task/<string:task_id>/chat", methods=["GET"])
1545
  def admin_view_chat(task_id):
 
1546
  try:
1547
  verify_admin(request.headers.get("Authorization"))
1548
  msgs = db_ref.child(f"chats/{task_id}").get() or {}
@@ -1555,8 +1638,35 @@ def admin_view_chat(task_id):
1555
  logger.error(f"[ADMIN VIEW CHAT] Error: {e}")
1556
  return jsonify({"error": "Internal server error"}), 500
1557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1558
  # -----------------------------------------------------------------------------
1559
- # 12. MAIN EXECUTION (HF Spaces)
1560
  # -----------------------------------------------------------------------------
1561
 
1562
  if __name__ == "__main__":
 
4
  import json
5
  import uuid
6
  import time
7
+ import math # Added for Location Math
8
  import traceback
9
  from datetime import datetime, timedelta
10
 
 
32
  app = Flask(__name__)
33
  CORS(app)
34
 
35
+ # --- HARDCODED ADMINS (MVP STRATEGY) ---
36
+ HARDCODED_ADMIN_EMAILS = [
37
+ "rairorr@gmail.com",
38
+ "carolrue7@gmail.com"
39
+ ]
40
+
41
  # --- Firebase Initialization ---
42
  try:
43
  credentials_json_string = os.environ.get("FIREBASE")
 
119
 
120
  def safe_float(x, default=None):
121
  try:
122
+ if x is None or x == "":
123
+ return default
124
  return float(x)
125
  except Exception:
126
  return default
 
134
  def normalize_text(s: str) -> str:
135
  return re.sub(r"\s+", " ", str(s or "")).strip().lower()
136
 
137
+ def haversine_distance(lat1, lon1, lat2, lon2):
138
+ """
139
+ Calculate the great circle distance in kilometers between two points
140
+ on the earth (specified in decimal degrees).
141
+ """
142
+ if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
143
+ return None
144
+
145
+ try:
146
+ # Convert decimal degrees to radians
147
+ lat1, lon1, lat2, lon2 = map(math.radians, [float(lat1), float(lon1), float(lat2), float(lon2)])
148
+
149
+ # Haversine formula
150
+ dlon = lon2 - lon1
151
+ dlat = lat2 - lat1
152
+ a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
153
+ c = 2 * math.asin(math.sqrt(a))
154
+ r = 6371 # Radius of earth in kilometers
155
+ return r * c
156
+ except Exception:
157
+ return None
158
+
159
  def send_text_request(model_name, prompt, image=None):
160
  """
161
  Helper: if image is provided, send [prompt, image].
 
214
  f"User profile missing in RTDB at /users/{uid}. "
215
  f"Call /api/auth/social-signin (or /api/auth/signup) once after login to bootstrap the profile."
216
  )
217
+
218
+ # Bypass role check if user is admin
219
+ if user_data.get("is_admin"):
220
+ return user_data
221
 
222
  role = (user_data.get("role") or "").lower().strip()
223
  if role not in allowed_roles:
 
229
  def get_or_create_profile(uid: str) -> dict:
230
  """
231
  Ensures /users/{uid} exists in RTDB for any authenticated user.
232
+ **UPDATED**: Checks hardcoded admin emails to force role=admin.
233
  """
234
  ref = db_ref.child(f"users/{uid}")
235
  user_data = ref.get()
236
+
237
+ fb_user = auth.get_user(uid)
238
+ email = fb_user.email or ""
239
+
240
+ # Check Admin Injection
241
+ is_hardcoded_admin = email in HARDCODED_ADMIN_EMAILS
242
+
243
+ # If user exists, update Admin status if needed
244
  if user_data:
245
+ patch = {}
246
+ # If they are on the list but not marked admin yet
247
+ if is_hardcoded_admin and not user_data.get("is_admin"):
248
+ patch["is_admin"] = True
249
+ patch["role"] = "admin" # Force role update
250
+
251
+ # Social signin patch for display name
252
+ if not user_data.get("displayName") and fb_user.display_name:
253
+ patch["displayName"] = fb_user.display_name
254
+
255
+ if patch:
256
+ ref.update(patch)
257
+ user_data = ref.get()
258
  return user_data
259
 
260
+ # Create new profile
261
+ role = "admin" if is_hardcoded_admin else "customer" # Default to customer unless on list
262
+
263
  new_user_data = {
264
+ "email": email,
265
  "displayName": fb_user.display_name or "",
266
  "phone": "",
267
  "city": "",
268
+ "role": role,
269
+ "is_admin": is_hardcoded_admin,
270
+ "verificationStatus": "unverified", # unverified | pending | verified | rejected
271
  "createdAt": now_iso()
272
  }
273
  ref.set(new_user_data)
 
330
  return jsonify({"ok": True, "service": "oneplus-server", "time": now_iso()}), 200
331
 
332
  # -----------------------------------------------------------------------------
333
+ # 4. AUTH & USER PROFILES
334
  # -----------------------------------------------------------------------------
335
 
336
  @app.route("/api/auth/signup", methods=["POST"])
 
356
 
357
  user = auth.create_user(email=email, password=password, display_name=display_name)
358
 
359
+ # Admin Injection logic for Signup
360
+ is_admin = False
361
+ if email in HARDCODED_ADMIN_EMAILS:
362
+ role = "admin"
363
+ is_admin = True
364
+
365
  user_data = {
366
  "email": email,
367
  "displayName": display_name,
368
  "phone": phone,
369
  "city": city,
370
  "role": role,
371
+ "is_admin": is_admin,
372
+ "verificationStatus": "unverified",
373
  "createdAt": now_iso()
374
  }
375
  db_ref.child(f"users/{user.uid}").set(user_data)
 
384
  @app.route("/api/auth/social-signin", methods=["POST"])
385
  def social_signin():
386
  """
387
+ Ensures RTDB user record exists. Social login happens on client.
 
388
  """
389
  uid = verify_token(request.headers.get("Authorization"))
390
  if not uid:
391
  return jsonify({"error": "Invalid or expired token"}), 401
392
 
 
 
 
393
  try:
394
+ # get_or_create_profile handles admin injection
395
+ user_data = get_or_create_profile(uid)
396
+ return jsonify({"success": True, "uid": uid, **user_data}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
  except Exception as e:
399
  logger.error(f"social_signin failed: {e}")
 
402
  @app.route("/api/auth/set-role", methods=["POST"])
403
  def set_role_after_social_signin():
404
  """
405
+ Set role after first social sign-in.
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  """
407
  uid = verify_token(request.headers.get("Authorization"))
408
  if not uid:
 
416
 
417
  try:
418
  user_ref = db_ref.child(f"users/{uid}")
419
+ user_data = get_or_create_profile(uid) # ensure exists
420
+
421
+ # IF ADMIN via injection, LOCK role changes
422
+ if user_data.get("is_admin"):
423
+ return jsonify({"success": True, "uid": uid, "profile": user_data, "note": "User is Admin, role locked."}), 200
 
 
 
 
 
 
 
 
 
 
424
 
425
  current_role = (user_data.get("role") or "").lower().strip()
426
 
 
442
 
443
  # One-way upgrade: customer -> tasker (allow ONCE)
444
  if current_role == "customer" and requested_role == "tasker":
 
445
  if (user_data.get("roleUpgradedAt") or "").strip():
446
  return jsonify({
447
  "error": "Role change blocked",
448
+ "reason": "Customer -> Tasker upgrade already used.",
 
 
449
  }), 409
450
 
451
  patch = {
 
457
  updated = user_ref.get() or {}
458
  return jsonify({"success": True, "uid": uid, "profile": updated, "note": "upgraded customer -> tasker"}), 200
459
 
460
+ # Block any other change
461
  return jsonify({
462
  "error": "Role change blocked",
463
  "reason": "Role flipping is not allowed.",
 
 
464
  }), 409
465
 
466
  except Exception as e:
467
  logger.error(f"[SET ROLE] failed: {e}")
 
468
  return jsonify({"error": "Internal server error"}), 500
469
 
470
  @app.route("/api/user/profile", methods=["GET"])
 
473
  if not uid:
474
  return jsonify({"error": "Invalid or expired token"}), 401
475
 
 
476
  try:
477
  user_data = get_or_create_profile(uid)
478
  return jsonify({"uid": uid, **user_data}), 200
 
483
 
484
  @app.route("/api/user/profile", methods=["PUT"])
485
  def update_user_profile():
486
+ """
487
+ Updates user profile.
488
+ **UPDATED**: Accepts 'lat', 'lng', 'serviceRadiusKm' for Location Matching.
489
+ """
490
  uid = verify_token(request.headers.get("Authorization"))
491
  if not uid:
492
  return jsonify({"error": "Invalid or expired token"}), 401
493
 
 
494
  try:
495
  _ = get_or_create_profile(uid)
496
  except Exception as e:
 
497
  return jsonify({"error": "Failed to bootstrap profile"}), 500
498
 
499
  data = request.get_json() or {}
 
504
  if key in data:
505
  allowed[key] = data.get(key)
506
 
507
+ # Role-specific (tasker) + Location fields
508
+ for key in ["skills", "categories", "bio", "serviceRadiusKm", "baseRate", "profilePhotoUrl", "availability", "lat", "lng"]:
509
  if key in data:
510
+ # Type safety for numeric
511
+ if key in ["lat", "lng", "baseRate", "serviceRadiusKm"]:
512
+ allowed[key] = safe_float(data.get(key))
513
+ else:
514
+ allowed[key] = data.get(key)
 
515
 
516
  if not allowed:
517
  return jsonify({"error": "No valid fields provided"}), 400
 
528
  return jsonify({"error": f"Failed to update profile: {str(e)}"}), 500
529
 
530
  # -----------------------------------------------------------------------------
531
+ # 5. AI & SMART CAPTURE
532
  # -----------------------------------------------------------------------------
533
 
534
  @app.route("/api/ai/smart-capture", methods=["POST"])
535
  def smart_capture():
536
  """
537
+ Customer uploads image -> Gemini Vision -> Structured Task Data.
 
 
 
 
 
 
 
 
 
 
538
  """
539
  uid = verify_token(request.headers.get("Authorization"))
540
  if not uid:
541
  return jsonify({"error": "Unauthorized"}), 401
542
 
543
  try:
 
544
  if "image" not in request.files:
545
  return jsonify({"error": "Image file is required (field name: image)"}), 400
546
 
 
599
 
600
  except Exception as e:
601
  logger.error(f"[SMART CAPTURE] Error: {e}")
 
602
  return jsonify({"error": "Internal server error"}), 500
603
 
604
  @app.route("/api/ai/improve-description", methods=["POST"])
605
  def improve_description():
606
  """
607
+ (RESTORED) User provides a short/vague description; AI rewrites professionally.
 
608
  """
609
  uid = verify_token(request.headers.get("Authorization"))
610
  if not uid:
 
641
  return jsonify({"success": True, "result": result}), 200
642
 
643
  # -----------------------------------------------------------------------------
644
+ # 6. TASKER VERIFICATION (NEW)
645
+ # -----------------------------------------------------------------------------
646
+
647
+ @app.route("/api/tasker/verify-docs", methods=["POST"])
648
+ def upload_verification_docs():
649
+ """
650
+ Tasker uploads ID (required) and Certificate (optional).
651
+ Status becomes 'pending'.
652
+ """
653
+ uid = verify_token(request.headers.get("Authorization"))
654
+ if not uid:
655
+ return jsonify({"error": "Unauthorized"}), 401
656
+
657
+ try:
658
+ # Only Tasker or Admin
659
+ user = require_role(uid, ["tasker", "admin"])
660
+
661
+ # Files
662
+ id_file = request.files.get("idDocument")
663
+ cert_file = request.files.get("certificate") # optional
664
+
665
+ if not id_file:
666
+ return jsonify({"error": "ID Document is required"}), 400
667
+
668
+ verification_data = {
669
+ "status": "pending",
670
+ "submittedAt": now_iso()
671
+ }
672
+
673
+ # Upload ID
674
+ ext = (id_file.mimetype or "").split("/")[-1] or "jpg"
675
+ path = f"users/{uid}/verification/id_{int(time.time())}.{ext}"
676
+ verification_data["idDocUrl"] = upload_to_storage(id_file.read(), path, id_file.mimetype)
677
+
678
+ # Upload Cert if exists
679
+ if cert_file:
680
+ ext = (cert_file.mimetype or "").split("/")[-1] or "jpg"
681
+ path = f"users/{uid}/verification/cert_{int(time.time())}.{ext}"
682
+ verification_data["certDocUrl"] = upload_to_storage(cert_file.read(), path, cert_file.mimetype)
683
+
684
+ # Update user profile
685
+ db_ref.child(f"users/{uid}").update({
686
+ "verificationStatus": "pending",
687
+ "verificationDocs": verification_data
688
+ })
689
+
690
+ return jsonify({"success": True, "status": "pending", "docs": verification_data}), 200
691
+
692
+ except PermissionError as e:
693
+ return jsonify({"error": str(e)}), 403
694
+ except Exception as e:
695
+ logger.error(f"[VERIFICATION UPLOAD] Error: {e}")
696
+ return jsonify({"error": "Internal server error"}), 500
697
+
698
+ # -----------------------------------------------------------------------------
699
+ # 7. TASKS (UPDATED WITH LOCATION)
700
  # -----------------------------------------------------------------------------
701
 
702
  @app.route("/api/tasks", methods=["POST"])
703
  def create_task():
704
  """
705
  Customer creates a task.
706
+ **UPDATED**: Accepts 'lat' and 'lng' from form data for location matching.
 
 
 
707
  """
708
  uid = verify_token(request.headers.get("Authorization"))
709
  if not uid:
710
  return jsonify({"error": "Unauthorized"}), 401
711
 
712
  try:
 
713
  profile = get_or_create_profile(uid)
714
 
 
715
  role = (profile.get("role") or "").lower().strip()
716
  if role not in ["customer", "admin"]:
717
  return jsonify({
718
  "error": "Forbidden",
719
+ "reason": f"Role '{role}' not allowed to create tasks.",
 
720
  }), 403
721
 
 
 
 
722
  # multipart
723
  category = request.form.get("category", "").strip()
724
  title = request.form.get("title", "").strip()
 
726
  city = request.form.get("city", "").strip()
727
  address = request.form.get("address", "").strip()
728
  budget = request.form.get("budget", "").strip()
729
+ schedule_at = request.form.get("scheduleAt", "").strip()
730
+ smart_capture_json = request.form.get("smartCapture", "").strip()
731
+
732
+ # Location Data
733
+ lat = safe_float(request.form.get("lat"))
734
+ lng = safe_float(request.form.get("lng"))
735
 
736
  if not category or not city or not description:
737
  return jsonify({"error": "category, city, and description are required"}), 400
 
757
  url = upload_to_storage(data_bytes, path, f.mimetype or "application/octet-stream")
758
  media_urls.append(url)
759
 
 
760
  smart_capture = None
761
  if smart_capture_json:
762
  try:
 
775
  "description": description or (smart_capture or {}).get("suggestedDescription") or "",
776
  "city": city,
777
  "address": address,
778
+
779
+ # Geo-location
780
+ "lat": lat,
781
+ "lng": lng,
782
 
783
+ "budget": budget,
784
+ "scheduleAt": schedule_at,
785
  "mediaUrls": media_urls,
786
 
787
  "smartCapture": smart_capture or {},
 
795
 
796
  db_ref.child(f"tasks/{task_id}").set(task_payload)
797
 
798
+ # Notify taskers (Updated logic)
799
  notify_taskers_for_new_task(task_payload)
800
 
801
  return jsonify({"success": True, "task": task_payload}), 201
802
 
803
  except PermissionError as e:
 
804
  return jsonify({"error": "Forbidden", "reason": str(e)}), 403
805
  except Exception as e:
806
  logger.error(f"[CREATE TASK] Error: {e}")
 
807
  return jsonify({"error": "Internal server error"}), 500
808
 
809
  def notify_taskers_for_new_task(task: dict):
810
  """
811
+ **UPDATED** matching:
812
+ - Distance check (Haversine) vs Service Radius
813
+ - Category check
 
814
  """
815
  try:
816
  users = db_ref.child("users").get() or {}
817
+ t_lat = task.get("lat")
818
+ t_lng = task.get("lng")
819
+ t_cat = normalize_text(task.get("category"))
820
+ t_city = normalize_text(task.get("city"))
821
 
822
  for tasker_id, u in users.items():
823
  if (u.get("role") or "").lower().strip() != "tasker":
824
  continue
825
+
826
+ # 1. Location Logic (Precision or City fallback)
827
+ u_lat = u.get("lat")
828
+ u_lng = u.get("lng")
829
+ radius = safe_float(u.get("serviceRadiusKm"), 50.0)
830
+
831
+ # If precise location available for both, use math
832
+ match_location = False
833
+ if t_lat and t_lng and u_lat and u_lng:
834
+ dist = haversine_distance(t_lat, t_lng, u_lat, u_lng)
835
+ if dist is not None and dist <= radius:
836
+ match_location = True
837
+ else:
838
+ # Fallback to City string match
839
+ ucity = normalize_text(u.get("city"))
840
+ if not ucity or (t_city and ucity == t_city):
841
+ match_location = True
842
+
843
+ if not match_location:
844
  continue
845
 
846
+ # 2. Category Match
847
  cats = u.get("categories") or []
848
  if isinstance(cats, str):
849
  cats = [c.strip() for c in cats.split(",") if c.strip()]
850
 
851
  if cats:
852
+ ok = any(normalize_text(c) == t_cat for c in cats)
853
  if not ok:
854
  continue
855
 
 
866
  @app.route("/api/tasks", methods=["GET"])
867
  def list_tasks():
868
  """
869
+ Role-aware list.
 
 
 
 
 
870
  """
871
  uid = verify_token(request.headers.get("Authorization"))
872
  if not uid:
 
894
  continue
895
 
896
  elif role == "tasker":
 
897
  if mine:
898
  if t.get("assignedTaskerId") != uid:
899
  continue
 
913
 
914
  out.append(t)
915
 
 
916
  out.sort(key=lambda x: x.get("createdAt") or "", reverse=True)
917
  return jsonify(out), 200
918
 
 
920
  logger.error(f"[LIST TASKS] Error: {e}")
921
  return jsonify({"error": "Internal server error"}), 500
922
 
923
+ @app.route("/api/tasker/recommended", methods=["GET"])
924
+ def recommended_tasks():
925
+ """
926
+ **NEW**: Smart Recommender for Taskers.
927
+ Ranks open tasks based on:
928
+ 1. Distance (Haversine)
929
+ 2. Category Match
930
+ """
931
+ uid = verify_token(request.headers.get("Authorization"))
932
+ if not uid: return jsonify({"error": "Unauthorized"}), 401
933
+
934
+ try:
935
+ user = db_ref.child(f"users/{uid}").get() or {}
936
+ if user.get("role") != "tasker":
937
+ return jsonify({"error": "Only taskers can view recommended feed"}), 403
938
+
939
+ u_lat = safe_float(user.get("lat"))
940
+ u_lng = safe_float(user.get("lng"))
941
+ u_cats = user.get("categories") or []
942
+ if isinstance(u_cats, str):
943
+ u_cats = [x.strip().lower() for x in u_cats.split(",")]
944
+ else:
945
+ u_cats = [str(x).lower() for x in u_cats]
946
+
947
+ all_tasks = db_ref.child("tasks").get() or {}
948
+ scored_tasks = []
949
+
950
+ for t in all_tasks.values():
951
+ if t.get("status") not in ["open", "bidding"]:
952
+ continue
953
+
954
+ score = 100
955
+
956
+ # Distance Logic
957
+ t_lat = safe_float(t.get("lat"))
958
+ t_lng = safe_float(t.get("lng"))
959
+ dist_km = None
960
+
961
+ if u_lat and u_lng and t_lat and t_lng:
962
+ dist_km = haversine_distance(u_lat, u_lng, t_lat, t_lng)
963
+ if dist_km is not None:
964
+ # Deduct 1 point per km
965
+ score -= dist_km
966
+
967
+ # Category Match
968
+ t_cat = normalize_text(t.get("category"))
969
+ if any(c in t_cat for c in u_cats):
970
+ score += 50 # Big bonus for matching skill
971
+
972
+ # Add verified bonus?
973
+
974
+ t["_debug_score"] = score
975
+ t["_distance_km"] = dist_km
976
+ scored_tasks.append(t)
977
+
978
+ # Sort: Higher score first
979
+ scored_tasks.sort(key=lambda x: x["_debug_score"], reverse=True)
980
+ return jsonify(scored_tasks), 200
981
+
982
+ except Exception as e:
983
+ logger.error(f"[RECOMMENDER] {e}")
984
+ return jsonify({"error": "Internal server error"}), 500
985
+
986
  @app.route("/api/tasks/<string:task_id>", methods=["GET"])
987
  def get_task(task_id):
988
  uid = verify_token(request.headers.get("Authorization"))
 
1013
  @app.route("/api/tasks/<string:task_id>", methods=["PUT"])
1014
  def update_task(task_id):
1015
  """
1016
+ Customer edit task.
1017
  """
1018
  uid = verify_token(request.headers.get("Authorization"))
1019
  if not uid:
 
1034
 
1035
  data = request.get_json() or {}
1036
  allowed = {}
1037
+ # Added lat/lng here too
1038
+ for key in ["category", "title", "description", "city", "address", "budget", "scheduleAt", "lat", "lng"]:
1039
  if key in data:
1040
  allowed[key] = data.get(key)
1041
 
 
1055
 
1056
  @app.route("/api/tasks/<string:task_id>/status", methods=["PUT"])
1057
  def update_task_status(task_id):
 
 
 
 
 
 
1058
  uid = verify_token(request.headers.get("Authorization"))
1059
  if not uid:
1060
  return jsonify({"error": "Unauthorized"}), 401
 
1088
  if task.get("status") in ["completed", "cancelled"]:
1089
  return jsonify({"error": "Task already closed"}), 400
1090
  task_ref.update({"status": "cancelled", "cancelledAt": now_iso()})
 
1091
  if task.get("assignedTaskerId"):
1092
  push_notification(task["assignedTaskerId"], "task_cancelled", "Task cancelled", "Customer cancelled the task.", {"taskId": task_id})
1093
  return jsonify({"success": True, "task": task_ref.get()}), 200
1094
 
1095
  if new_status == "completed":
 
1096
  if task.get("status") not in ["assigned", "in_progress"]:
1097
  return jsonify({"error": "Task not in a completable state"}), 400
1098
  task_ref.update({"status": "completed", "completedAt": now_iso()})
 
1110
  if new_status not in ["on_the_way", "in_progress", "completed"]:
1111
  return jsonify({"error": "Invalid tasker status update"}), 400
1112
 
 
1113
  task_ref.update({"status": new_status, "updatedAt": now_iso()})
 
 
1114
  push_notification(task["createdBy"], "task_update", "Task update", f"Task status: {new_status}", {"taskId": task_id})
1115
  return jsonify({"success": True, "task": task_ref.get()}), 200
1116
 
 
1123
  @app.route("/api/tasks/<string:task_id>", methods=["DELETE"])
1124
  def delete_task(task_id):
1125
  """
1126
+ (RESTORED) Customer can delete only if open/bidding and no assignment.
 
1127
  """
1128
  uid = verify_token(request.headers.get("Authorization"))
1129
  if not uid:
 
1166
  return jsonify({"error": "Internal server error"}), 500
1167
 
1168
  # -----------------------------------------------------------------------------
1169
+ # 8. BIDDING
1170
  # -----------------------------------------------------------------------------
1171
 
1172
  @app.route("/api/tasks/<string:task_id>/bids", methods=["POST"])
1173
  def submit_bid(task_id):
1174
  """
1175
+ Tasker submits bid.
1176
+ **UPDATED**: Snapshots 'taskerVerified' status into the bid for the badge UI.
1177
  """
1178
  uid = verify_token(request.headers.get("Authorization"))
1179
  if not uid:
1180
  return jsonify({"error": "Unauthorized"}), 401
1181
 
1182
  try:
1183
+ user = require_role(uid, ["tasker", "admin"])
1184
 
1185
  task = db_ref.child(f"tasks/{task_id}").get()
1186
  if not task:
 
1202
  "bidId": bid_id,
1203
  "taskId": task_id,
1204
  "taskerId": uid,
1205
+ "taskerName": user.get("displayName"),
1206
+ "taskerPhoto": user.get("profilePhotoUrl"),
1207
+ "taskerVerified": (user.get("verificationStatus") == "verified"), # THE BADGE
1208
+
1209
  "price": price,
1210
  "timeline": timeline,
1211
  "message": message,
 
1214
  }
1215
  db_ref.child(f"bids/{task_id}/{bid_id}").set(bid)
1216
 
 
1217
  if task.get("status") == "open":
1218
  db_ref.child(f"tasks/{task_id}").update({"status": "bidding", "updatedAt": now_iso()})
1219
 
 
1220
  push_notification(
1221
  to_uid=task["createdBy"],
1222
  notif_type="new_bid",
 
1235
 
1236
  @app.route("/api/tasks/<string:task_id>/bids", methods=["GET"])
1237
  def list_bids(task_id):
 
 
 
 
 
1238
  uid = verify_token(request.headers.get("Authorization"))
1239
  if not uid:
1240
  return jsonify({"error": "Unauthorized"}), 401
 
1261
  return jsonify(out), 200
1262
 
1263
  if role == "tasker":
 
1264
  if task.get("assignedTaskerId") == uid:
1265
  out.sort(key=lambda x: x.get("createdAt") or "", reverse=True)
1266
  return jsonify(out), 200
 
1276
 
1277
  @app.route("/api/tasks/<string:task_id>/select-bid", methods=["PUT"])
1278
  def select_bid(task_id):
 
 
 
 
 
 
 
1279
  uid = verify_token(request.headers.get("Authorization"))
1280
  if not uid:
1281
  return jsonify({"error": "Unauthorized"}), 401
 
1308
  "updatedAt": now_iso()
1309
  })
1310
 
1311
+ # mark bid as accepted, others as rejected
1312
  bids = db_ref.child(f"bids/{task_id}").get() or {}
1313
  for bkey, b in bids.items():
1314
  st = "accepted" if bkey == bid_id else "rejected"
1315
  db_ref.child(f"bids/{task_id}/{bkey}").update({"status": st})
1316
 
 
1317
  push_notification(
1318
  to_uid=tasker_id,
1319
  notif_type="bid_accepted",
 
1322
  meta={"taskId": task_id, "bidId": bid_id}
1323
  )
1324
 
 
1325
  push_notification(
1326
  to_uid=task["createdBy"],
1327
  notif_type="task_assigned",
1328
  title="Task assigned",
1329
+ body="You assigned the task to a tasker.",
1330
  meta={"taskId": task_id, "assignedTaskerId": tasker_id}
1331
  )
1332
 
 
1339
  return jsonify({"error": "Internal server error"}), 500
1340
 
1341
  # -----------------------------------------------------------------------------
1342
+ # 9. CHAT
1343
  # -----------------------------------------------------------------------------
1344
 
1345
  @app.route("/api/chats/<string:task_id>/messages", methods=["GET"])
 
1371
  @app.route("/api/chats/<string:task_id>/messages", methods=["POST"])
1372
  def send_message(task_id):
1373
  """
1374
+ Send message with optional file.
 
 
 
1375
  """
1376
  uid = verify_token(request.headers.get("Authorization"))
1377
  if not uid:
 
1422
  }
1423
  db_ref.child(f"chats/{task_id}/{msg_id}").set(msg)
1424
 
 
1425
  other_uid = None
1426
  if uid == task.get("createdBy"):
1427
  other_uid = task.get("assignedTaskerId") or None
 
1444
  return jsonify({"error": "Internal server error"}), 500
1445
 
1446
  # -----------------------------------------------------------------------------
1447
+ # 10. NOTIFICATIONS
1448
  # -----------------------------------------------------------------------------
1449
 
1450
  @app.route("/api/notifications", methods=["GET"])
 
1464
 
1465
  @app.route("/api/notifications/<string:notif_id>/read", methods=["PUT"])
1466
  def mark_notification_read(notif_id):
1467
+ """(RESTORED)"""
1468
  uid = verify_token(request.headers.get("Authorization"))
1469
  if not uid:
1470
  return jsonify({"error": "Unauthorized"}), 401
 
1481
  return jsonify({"error": "Internal server error"}), 500
1482
 
1483
  # -----------------------------------------------------------------------------
1484
+ # 11. REVIEWS
1485
  # -----------------------------------------------------------------------------
1486
 
1487
  @app.route("/api/tasks/<string:task_id>/review", methods=["POST"])
 
1534
  return jsonify({"error": "Internal server error"}), 500
1535
 
1536
  # -----------------------------------------------------------------------------
1537
+ # 12. ADMIN
1538
  # -----------------------------------------------------------------------------
1539
 
1540
  @app.route("/api/admin/overview", methods=["GET"])
 
1544
 
1545
  users = db_ref.child("users").get() or {}
1546
  tasks = db_ref.child("tasks").get() or {}
1547
+
1548
  total_users = len(users)
1549
  total_taskers = sum(1 for u in users.values() if (u.get("role") or "").lower().strip() == "tasker")
1550
  total_customers = sum(1 for u in users.values() if (u.get("role") or "").lower().strip() == "customer")
1551
+
1552
+ # New: Pending verifications
1553
+ pending_verifications = sum(1 for u in users.values() if u.get("verificationStatus") == "pending")
1554
 
1555
  by_status = {}
1556
  for t in tasks.values():
1557
  s = t.get("status") or "unknown"
1558
  by_status[s] = by_status.get(s, 0) + 1
1559
 
 
 
 
 
 
 
 
1560
  return jsonify({
1561
  "uid": admin_uid,
1562
+ "stats": { # Unified stats block
1563
+ "users": total_users,
1564
+ "customers": total_customers,
1565
+ "taskers": total_taskers,
1566
+ "tasks": len(tasks),
1567
+ "pendingVerifications": pending_verifications
1568
+ },
1569
+ "dashboardStats": { # Legacy/Duplicate block just in case FE uses it
1570
+ "users": {"total": total_users},
1571
+ "tasks": {"total": len(tasks)}
 
 
 
1572
  }
1573
  }), 200
1574
 
 
1608
 
1609
  @app.route("/api/admin/users/<string:uid>/deactivate", methods=["PUT"])
1610
  def admin_deactivate_user(uid):
1611
+ """(RESTORED)"""
 
 
 
 
1612
  try:
1613
  verify_admin(request.headers.get("Authorization"))
1614
  ref = db_ref.child(f"users/{uid}")
 
1625
 
1626
  @app.route("/api/admin/task/<string:task_id>/chat", methods=["GET"])
1627
  def admin_view_chat(task_id):
1628
+ """(RESTORED)"""
1629
  try:
1630
  verify_admin(request.headers.get("Authorization"))
1631
  msgs = db_ref.child(f"chats/{task_id}").get() or {}
 
1638
  logger.error(f"[ADMIN VIEW CHAT] Error: {e}")
1639
  return jsonify({"error": "Internal server error"}), 500
1640
 
1641
+ @app.route("/api/admin/users/<string:target_uid>/verify", methods=["PUT"])
1642
+ def admin_verify_user(target_uid):
1643
+ """
1644
+ **NEW**: Admin approves or rejects tasker documents.
1645
+ Payload: { "status": "verified" | "rejected" }
1646
+ """
1647
+ try:
1648
+ verify_admin(request.headers.get("Authorization"))
1649
+ data = request.get_json() or {}
1650
+ status = data.get("status")
1651
+ if status not in ["verified", "rejected"]:
1652
+ return jsonify({"error": "Invalid status"}), 400
1653
+
1654
+ db_ref.child(f"users/{target_uid}").update({
1655
+ "verificationStatus": status,
1656
+ "verifiedAt": now_iso() if status == "verified" else None
1657
+ })
1658
+
1659
+ push_notification(target_uid, "verification_update", "Account Update", f"Your verification status is now: {status}")
1660
+
1661
+ return jsonify({"success": True}), 200
1662
+ except PermissionError:
1663
+ return jsonify({"error": "Admin required"}), 403
1664
+ except Exception as e:
1665
+ logger.error(f"[ADMIN VERIFY] Error: {e}")
1666
+ return jsonify({"error": "Internal server error"}), 500
1667
+
1668
  # -----------------------------------------------------------------------------
1669
+ # 13. MAIN EXECUTION
1670
  # -----------------------------------------------------------------------------
1671
 
1672
  if __name__ == "__main__":