Files
bellsystems-cp/backend/users/service.py
T
bonaminandClaude Opus 5.5 c1df3b5aa3 fix(users): console-created users use the Firebase uid as doc ID
create_user wrote the profile with .add() (random doc ID). On first login the
FlutterFlow app looks for users/{uid}, doesn't find it, and creates a second,
bare doc - so every console-created user ended up duplicated, and devices
assigned in the console pointed at the doc the app never reads.

Now the profile is written to users/{uid} with created_time set, and the email
is lowercased to match what Firebase Auth stores. If the Firestore write
fails, the just-created Auth account is deleted so no orphan is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:31:34 +03:00

395 lines
13 KiB
Python

from datetime import datetime, timezone
from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove
from firebase_admin import auth as firebase_auth
from shared.firebase import get_db, get_bucket
from shared.exceptions import NotFoundError, ValidationError
from users.models import UserCreate, UserUpdate, UserInDB
COLLECTION = "users"
# Firestore "in" queries accept at most 30 values.
_IN_QUERY_LIMIT = 30
def device_serial_of(device_data: dict) -> str:
"""The board serial as used in MQTT topics (vesper/{serial}/...).
`serial_number` is what gets flashed into NVS and what the firmware uses
as its MQTT username/topic id. Legacy docs predating the lifecycle system
only carry it in `device_id`.
"""
return (device_data.get("serial_number") or device_data.get("device_id") or "").strip()
def stage_device_serial_link(batch, user_ref: DocumentReference, serial: str, linked: bool) -> None:
"""Add (linked=True) or remove the serial from the user's `device_serials`
inside the caller's write batch, so it commits together with the device's
`user_list` change."""
if not serial:
return
op = ArrayUnion([serial]) if linked else ArrayRemove([serial])
batch.update(user_ref, {"device_serials": op})
def invalidate_mqtt_acl_cache(user_data: dict | None) -> None:
"""Drop the MQTT ACL cache entry for this user's Firebase uid."""
uid = (user_data or {}).get("uid") or ""
if uid:
from mqtt.app_users import invalidate
invalidate(uid)
def _convert_firestore_value(val):
"""Convert Firestore-specific types (Timestamp, DocumentReference) to strings."""
if isinstance(val, datetime):
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
if isinstance(val, DocumentReference):
return val.path
return val
def _sanitize_dict(d: dict) -> dict:
"""Recursively convert Firestore-native types in a dict to plain strings."""
result = {}
for k, v in d.items():
if isinstance(v, dict):
result[k] = _sanitize_dict(v)
elif isinstance(v, list):
result[k] = [
_sanitize_dict(item) if isinstance(item, dict)
else _convert_firestore_value(item)
for item in v
]
else:
result[k] = _convert_firestore_value(v)
return result
def _doc_to_user(doc) -> UserInDB:
"""Convert a Firestore document snapshot to a UserInDB model."""
data = _sanitize_dict(doc.to_dict())
return UserInDB(id=doc.id, **data)
def list_users(
search: str | None = None,
status: str | None = None,
) -> list[UserInDB]:
"""List users with optional filters."""
db = get_db()
ref = db.collection(COLLECTION)
query = ref
if status:
query = query.where("status", "==", status)
docs = query.stream()
results = []
for doc in docs:
user = _doc_to_user(doc)
if search:
search_lower = search.lower()
name_match = search_lower in (user.display_name or "").lower()
email_match = search_lower in (user.email or "").lower()
phone_match = search_lower in (user.phone_number or "").lower()
uid_match = search_lower in (user.uid or "").lower()
if not (name_match or email_match or phone_match or uid_match):
continue
results.append(user)
return results
def get_user(user_doc_id: str) -> UserInDB:
"""Get a single user by Firestore document ID."""
db = get_db()
doc = db.collection(COLLECTION).document(user_doc_id).get()
if not doc.exists:
raise NotFoundError("User")
return _doc_to_user(doc)
def create_user(data: UserCreate) -> UserInDB:
"""Create a new user: a Firebase Auth account (so they can log into the app
immediately) plus the matching Firestore profile document.
The profile doc ID MUST be the Firebase Auth uid. The FlutterFlow app looks
up `users/{uid}` on login and creates a bare doc there if it's missing, and
MQTT auth (mqtt/app_users.py) + device `user_list` paths assume the same.
"""
if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.")
# Firebase Auth lowercases emails; store the same form so the profile
# matches what the app sees for the signed-in user.
email = (data.email or "").strip().lower()
if not email:
raise ValidationError("Email is required.")
db = get_db()
doc_data = data.model_dump(exclude={"password"})
doc_data["email"] = email
doc_data["friendsList"] = []
doc_data["friendsInvited"] = []
doc_data["created_time"] = datetime.now(timezone.utc)
try:
firebase_user = firebase_auth.create_user(
email=email,
password=data.password,
display_name=data.display_name or None,
)
except firebase_auth.EmailAlreadyExistsError:
raise ValidationError(f"A user with email {email} already exists.")
doc_data["uid"] = firebase_user.uid
try:
db.collection(COLLECTION).document(firebase_user.uid).set(doc_data)
except Exception:
# Don't leave an Auth account behind with no profile doc.
firebase_auth.delete_user(firebase_user.uid)
raise
return UserInDB(id=firebase_user.uid, **_sanitize_dict(doc_data))
def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
"""Update an existing user document. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
update_data = data.model_dump(exclude_none=True)
doc_ref.update(update_data)
invalidate_mqtt_acl_cache(doc.to_dict()) # status may have changed
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def delete_user(user_doc_id: str) -> None:
"""Delete a user document from Firestore, plus their Firebase Auth account if one exists."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if uid:
try:
firebase_auth.delete_user(uid)
except firebase_auth.UserNotFoundError:
pass
doc_ref.delete()
invalidate_mqtt_acl_cache(doc.to_dict())
def block_user(user_doc_id: str) -> UserInDB:
"""Block a user by setting their status to 'blocked'."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
doc_ref.update({"status": "blocked"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def unblock_user(user_doc_id: str) -> UserInDB:
"""Unblock a user by setting their status to 'active'."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
doc_ref.update({"status": "active"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def assign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
"""Assign a device to a user by adding user ref to device's user_list."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
# Verify device exists
device_ref = db.collection("devices").document(device_doc_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise NotFoundError("Device")
# Add user path to device's user_list if not already there
device_data = device_doc.to_dict()
user_list = device_data.get("user_list", [])
user_path = f"users/{user_doc_id}"
# Check if already assigned (handle both string paths and DocumentReferences)
already_assigned = False
for entry in user_list:
if isinstance(entry, DocumentReference):
if entry.path == user_path:
already_assigned = True
break
elif entry == user_path:
already_assigned = True
break
# Device user_list and the user's device_serials are written in one atomic batch.
# device_serials is re-synced even when already assigned, to heal drift.
batch = db.batch()
if not already_assigned:
user_list.append(user_path)
batch.update(device_ref, {"user_list": user_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=True)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
def unassign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
"""Remove a user from a device's user_list."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
# Verify device exists
device_ref = db.collection("devices").document(device_doc_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise NotFoundError("Device")
# Remove user from device's user_list
device_data = device_doc.to_dict()
user_list = device_data.get("user_list", [])
user_path = f"users/{user_doc_id}"
new_list = []
for entry in user_list:
if isinstance(entry, DocumentReference):
if entry.path != user_path:
new_list.append(entry)
elif entry != user_path:
new_list.append(entry)
batch = db.batch()
batch.update(device_ref, {"user_list": new_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=False)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
def get_user_devices(user_doc_id: str) -> list[dict]:
"""Get all devices assigned to a user, via the user's `device_serials`."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
serials = [s for s in dict.fromkeys(user_doc.to_dict().get("device_serials") or []) if s]
# Match on serial_number first; fall back to the legacy device_id field for the rest.
found: dict[str, object] = {}
for field in ("serial_number", "device_id"):
remaining = [s for s in serials if s not in found]
for i in range(0, len(remaining), _IN_QUERY_LIMIT):
chunk = remaining[i:i + _IN_QUERY_LIMIT]
for doc in db.collection("devices").where(field, "in", chunk).stream():
sn = doc.to_dict().get(field)
if sn not in found:
found[sn] = doc
devices = []
for sn in serials:
doc = found.get(sn)
if doc is None:
continue
data = doc.to_dict()
devices.append({
"id": doc.id,
"device_name": data.get("device_name", ""),
"device_id": data.get("device_id", ""),
"device_location": data.get("device_location", ""),
"is_Online": data.get("is_Online", False),
})
return devices
def set_password(user_doc_id: str, new_password: str) -> None:
"""Set a Firebase Auth password for a user via their Firestore document ID.
Requires the user document to have a non-empty `uid` field — populated
automatically for users who registered via the Flutter app.
"""
if not new_password or len(new_password) < 6:
raise ValidationError("Password must be at least 6 characters.")
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if not uid:
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
try:
firebase_auth.update_user(uid, password=new_password)
except Exception as e:
raise RuntimeError(f"Firebase Auth error: {e}")
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
db = get_db()
bucket = get_bucket()
if not bucket:
raise RuntimeError("Firebase Storage not initialized")
# Verify user exists
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
ext = filename.rsplit(".", 1)[-1] if "." in filename else "jpg"
storage_path = f"users/{user_doc_id}/uploads/profile.{ext}"
blob = bucket.blob(storage_path)
blob.upload_from_string(file_bytes, content_type=content_type)
blob.make_public()
photo_url = blob.public_url
doc_ref.update({"photo_url": photo_url})
return photo_url