Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -175,14 +175,46 @@ def extract_json_from_text(text: str):
|
|
| 175 |
def require_role(uid: str, allowed_roles: list[str]) -> dict:
|
| 176 |
"""
|
| 177 |
Reads user profile and checks role.
|
| 178 |
-
Returns user_data if ok, else raises PermissionError.
|
| 179 |
"""
|
| 180 |
-
user_data = db_ref.child(f"users/{uid}").get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
role = (user_data.get("role") or "").lower().strip()
|
| 182 |
if role not in allowed_roles:
|
| 183 |
-
raise PermissionError(f"Role '{role}' not allowed.")
|
|
|
|
| 184 |
return user_data
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
def push_notification(to_uid: str, notif_type: str, title: str, body: str, meta: dict | None = None):
|
| 187 |
"""
|
| 188 |
In-app notification stored in RTDB:
|
|
@@ -298,10 +330,16 @@ def social_signin():
|
|
| 298 |
|
| 299 |
try:
|
| 300 |
fb_user = auth.get_user(uid)
|
|
|
|
|
|
|
| 301 |
if user_data:
|
| 302 |
-
|
| 303 |
if not user_data.get("displayName") and fb_user.display_name:
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
user_data = user_ref.get()
|
| 306 |
return jsonify({"uid": uid, **(user_data or {})}), 200
|
| 307 |
|
|
@@ -322,16 +360,117 @@ def social_signin():
|
|
| 322 |
logger.error(f"social_signin failed: {e}")
|
| 323 |
return jsonify({"error": f"Failed to create user profile: {str(e)}"}), 500
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
@app.route("/api/user/profile", methods=["GET"])
|
| 326 |
def get_user_profile():
|
| 327 |
uid = verify_token(request.headers.get("Authorization"))
|
| 328 |
if not uid:
|
| 329 |
return jsonify({"error": "Invalid or expired token"}), 401
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
@app.route("/api/user/profile", methods=["PUT"])
|
| 337 |
def update_user_profile():
|
|
@@ -339,6 +478,13 @@ def update_user_profile():
|
|
| 339 |
if not uid:
|
| 340 |
return jsonify({"error": "Invalid or expired token"}), 401
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
data = request.get_json() or {}
|
| 343 |
allowed = {}
|
| 344 |
|
|
@@ -348,11 +494,15 @@ def update_user_profile():
|
|
| 348 |
allowed[key] = data.get(key)
|
| 349 |
|
| 350 |
# Role-specific (tasker)
|
| 351 |
-
# (customer can send them too, but we’ll store; frontend decides)
|
| 352 |
for key in ["skills", "categories", "bio", "serviceRadiusKm", "baseRate", "profilePhotoUrl", "availability"]:
|
| 353 |
if key in data:
|
| 354 |
allowed[key] = data.get(key)
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
if not allowed:
|
| 357 |
return jsonify({"error": "No valid fields provided"}), 400
|
| 358 |
|
|
@@ -511,7 +661,20 @@ def create_task():
|
|
| 511 |
return jsonify({"error": "Unauthorized"}), 401
|
| 512 |
|
| 513 |
try:
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
# multipart
|
| 517 |
category = request.form.get("category", "").strip()
|
|
@@ -558,7 +721,7 @@ def create_task():
|
|
| 558 |
task_payload = {
|
| 559 |
"taskId": task_id,
|
| 560 |
"createdBy": uid,
|
| 561 |
-
"createdByName":
|
| 562 |
"createdAt": created_at,
|
| 563 |
|
| 564 |
"category": category,
|
|
@@ -588,7 +751,8 @@ def create_task():
|
|
| 588 |
return jsonify({"success": True, "task": task_payload}), 201
|
| 589 |
|
| 590 |
except PermissionError as e:
|
| 591 |
-
|
|
|
|
| 592 |
except Exception as e:
|
| 593 |
logger.error(f"[CREATE TASK] Error: {e}")
|
| 594 |
logger.error(traceback.format_exc())
|
|
|
|
| 175 |
def require_role(uid: str, allowed_roles: list[str]) -> dict:
|
| 176 |
"""
|
| 177 |
Reads user profile and checks role.
|
| 178 |
+
Returns user_data if ok, else raises PermissionError with a clear message.
|
| 179 |
"""
|
| 180 |
+
user_data = db_ref.child(f"users/{uid}").get()
|
| 181 |
+
if not user_data:
|
| 182 |
+
raise PermissionError(
|
| 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:
|
| 189 |
+
raise PermissionError(f"Role '{role}' not allowed. Allowed roles: {allowed_roles}")
|
| 190 |
+
|
| 191 |
return user_data
|
| 192 |
|
| 193 |
+
|
| 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)
|
| 215 |
+
return new_user_data
|
| 216 |
+
|
| 217 |
+
|
| 218 |
def push_notification(to_uid: str, notif_type: str, title: str, body: str, meta: dict | None = None):
|
| 219 |
"""
|
| 220 |
In-app notification stored in RTDB:
|
|
|
|
| 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 |
|
|
|
|
| 360 |
logger.error(f"social_signin failed: {e}")
|
| 361 |
return jsonify({"error": f"Failed to create user profile: {str(e)}"}), 500
|
| 362 |
|
| 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:
|
| 383 |
+
return jsonify({"error": "Invalid or expired token"}), 401
|
| 384 |
+
|
| 385 |
+
data = request.get_json() or {}
|
| 386 |
+
requested_role = (data.get("role") or "").lower().strip()
|
| 387 |
+
|
| 388 |
+
if requested_role not in ["customer", "tasker"]:
|
| 389 |
+
return jsonify({"error": "Invalid role. Use customer or tasker."}), 400
|
| 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 |
+
|
| 411 |
+
# Idempotent: already same role
|
| 412 |
+
if current_role and current_role == requested_role:
|
| 413 |
+
updated = user_ref.get() or {}
|
| 414 |
+
return jsonify({"success": True, "uid": uid, "profile": updated, "note": "role unchanged"}), 200
|
| 415 |
+
|
| 416 |
+
# If role is empty/missing -> allow setting
|
| 417 |
+
if not current_role:
|
| 418 |
+
patch = {
|
| 419 |
+
"role": requested_role,
|
| 420 |
+
"roleSetAt": now_iso(),
|
| 421 |
+
"updatedAt": now_iso(),
|
| 422 |
+
}
|
| 423 |
+
user_ref.update(patch)
|
| 424 |
+
updated = user_ref.get() or {}
|
| 425 |
+
return jsonify({"success": True, "uid": uid, "profile": updated}), 200
|
| 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 = {
|
| 439 |
+
"role": "tasker",
|
| 440 |
+
"roleUpgradedAt": now_iso(),
|
| 441 |
+
"updatedAt": now_iso(),
|
| 442 |
+
}
|
| 443 |
+
user_ref.update(patch)
|
| 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"])
|
| 461 |
def get_user_profile():
|
| 462 |
uid = verify_token(request.headers.get("Authorization"))
|
| 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
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.error(f"get_user_profile failed: {e}")
|
| 472 |
+
return jsonify({"error": "Failed to load profile"}), 500
|
| 473 |
+
|
| 474 |
|
| 475 |
@app.route("/api/user/profile", methods=["PUT"])
|
| 476 |
def update_user_profile():
|
|
|
|
| 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 {}
|
| 489 |
allowed = {}
|
| 490 |
|
|
|
|
| 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
|
| 508 |
|
|
|
|
| 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()
|
|
|
|
| 721 |
task_payload = {
|
| 722 |
"taskId": task_id,
|
| 723 |
"createdBy": uid,
|
| 724 |
+
"createdByName": profile.get("displayName") or "",
|
| 725 |
"createdAt": created_at,
|
| 726 |
|
| 727 |
"category": category,
|
|
|
|
| 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())
|